Skip to main content

Useful Scripts

Some useful scripts for generating manifest files for use in https://github.com/M3-org/characterstudio. YMMV.


Adding Your Own Traits​

This is for the typical usecase of creating your own avatar builder with your traits, like you see on the left side of the screenshot below.

Note: You'll need to generate your own screenshots. I recommend screenshot-glb which works with VRM files as well and keep the base filenames same as the VRM files.

Screenshot from 2024-02-19 21-15-46

You will need to modify the paths for the templates, it's currently configured for https://github.com/m3-org/loot-assets.

Here is how the folder structure looks before generating manifest.json

image

This is the script I'm using for generating a manifest for https://github.com/m3-org/loot-assets

import os
import json

def generate_manifest(directory_path):
manifest_template = {
"assetsLocation": "./loot/",
"format": "vrm",
"traitsDirectory": "./loot/models/",
"thumbnailsDirectory": "./loot/models/",
"exportScale": 1,
"animationPath": get_animation_paths(),
"traitIconsDirectorySvg": "./loot/icons/",
"defaultCullingLayer": -1,
"defaultCullingDistance": [0.1, 0.01],
"initialTraits": ["Body", "Head", "Hand", "Foot", "Chest", "Waist", "Neck"],
"offset": [0.0, 0.48, 0.0],
"traits": generate_traits(directory_path),
"textureCollections": [],
"colorCollections": []
}

return json.dumps(manifest_template, indent=2)

def get_animation_paths():
animation_directory = "./animations"
animation_paths = [os.path.join(animation_directory, file) for file in os.listdir(animation_directory) if file.endswith(".fbx")]
return sorted(animation_paths)


def generate_traits(directory_path):
traits = []

trait_culling_layers = {
"Body": 0,
"Head": -1,
"Hand": -1,
"Foot": -1,
"Chest": 0,
"Neck": -1,
"Waist": -1
}

for trait_name, culling_layer in trait_culling_layers.items():
trait = {
"trait": trait_name,
"name": trait_name.capitalize(),
"icon": "",
"type": "mesh",
"iconGradient": "",
"iconSvg": f"{trait_name.upper()}.svg",
"cullingLayer": culling_layer,
"cameraTarget": {"distance": 3.0, "height": 0.8},
"cullingDistance": [0.1, 0.01],
"collection": generate_collection(directory_path, trait_name)
}

traits.append(trait)

return traits

def generate_collection(directory_path, trait_name):
trait_directory_path = os.path.join(directory_path, trait_name)

return [
{
"id": entry[:-4],
"name": entry[:-4].replace("_", " "),
"directory": f"{trait_name}/{entry}",
"thumbnail": f"{trait_name}/{entry[:-4]}.png"
}
for entry in os.listdir(trait_directory_path)
if entry.endswith(".vrm")
]

if __name__ == "__main__":
directory_path = "./models/"
manifest_content = generate_manifest(directory_path)

with open("./models/manifest.json", "w") as manifest_file:
manifest_file.write(manifest_content)

print("Manifest file generated successfully.")

This is how to run it

python3 scripts/generate_manifest.py


Based on Existing NFT Collection​

Handling Messy NFT Metadata

Oftentimes metadata traits in NFT collections will contain a bunch of special characters and may not always match 1:1 with the visual trait. In these cases you will want a higher abstraction level system if you want to do things like to match the original metadata filenames to renamed versions you're using when handling the actual assets.

First, we create a schema on how we go about renaming from the NFT metadata traits to a machine readable version with a CSV file containing every unique trait per row. For Anata project it looks like this:

Body,trait_type,Category,Original,Rename
Feminine,Brace,BRACE,Abstract Vision Brace,Abstract_Vision_Brace
Feminine,Brace,BRACE,Arrow Brace Blue,Arrow_Brace_Blue
Feminine,Brace,BRACE,Arrow Brace Fallen Angel,Arrow_Brace_Fallen_Angel
Feminine,Brace,BRACE,Arrow Brace Green,Arrow_Brace_Green
Feminine,Brace,BRACE,Arrow Brace Holy,Arrow_Brace_Holy

We include the body and category because we needed to rename the trait_type values too since some contained special characters and also because there were occasions where the same trait value showed up in different categories. We save this file as master_renamed_f

Then with this script we generate a manifest file per NFT ID, using the JSON of the original metadata + the CSV file containing original and renamed values as arguments like so:

import argparse
import csv
import json
import os

# Define the culling layer mapping
# -1 = always show
# 0 = usually body / skin
# 1 = will cull with 0 (so usually clothing)
# 2 = will cull with 0 and 1 (so usually hair)
# etc..
culling_layer_mapping = {
"Body": 0,
"Brace": 0,
"Clips and Kanzashi": -1,
"Clothing": 1,
"Earring": -1,
"Face Other": -1,
"Glasses": -1,
"Hair": 1,
"Hair Accessory Other": -1,
"Halos": -1,
"Hats": -1,
"Head": -1,
"Head Accessory Other": -1,
"Masks": -1,
"Neck": -1,
"Ribbons and Bows": -1,
"Sigil": -1,
"Tail": -1,
"Weapon": -1,
"Weapon Brace": -1,
"Wings": -1
}

def read_csv_mapping(csv_file_path):
id_mapping = {}

with open(csv_file_path, 'r') as csvfile:
csv_reader = csv.DictReader(csvfile)
for row in csv_reader:
original_name = row['Original']
renamed_name = row['Rename']

id_mapping[original_name] = renamed_name

return id_mapping

def get_animation_paths(directory_path):
animation_directory = os.path.join(directory_path, "_animations")
animation_paths = [os.path.join("./anata-vrm/_animations", file) for file in os.listdir(animation_directory) if file.endswith(".fbx")]
return sorted(animation_paths)


def get_id_from_mapping(trait_name, id_mapping):
# Get the original name directly using trait_name
renamed_name = id_mapping.get(trait_name, None)

return renamed_name if renamed_name is not None else trait_name

def create_manifest(input_file, csv_file, id_mapping):
"""
Generate a *_manifest.json file based on a given *_attributes.json file and a CSV file.

Args:
input_file (str): The input JSON file (*_attributes.json).
csv_file (str): The input CSV file.

This script takes an input JSON file (*_attributes.json) and a CSV file and generates a corresponding
*_manifest.json file with specific formatting. It maps trait types to culling layers
based on the culling_layer_mapping and creates the manifest accordingly.
"""

with open(input_file, 'r') as f:
data = json.load(f)

with open(csv_file, 'r') as csv_file:
csv_reader = csv.DictReader(csv_file)
name_mapping = {row['Original']: row['Rename'] for row in csv_reader}

folder_name = data["name"]
output_file = f"{folder_name}_manifest.json"

# Define the template for the manifest
manifest = {
"thumbnail": f"./anata-vrm/_thumbnails/t_{folder_name}.jpg",
"format": "vrm",
"traitsDirectory": f"./anata-vrm/male/",
"thumbnailsDirectory": f"./anata-vrm/male/",
"exportScale": 0.7,
"animationPath": get_animation_paths(directory_path),
"traitIconsDirectorySvg": "./assets/_icons/",
"requiredTraits": ["Body"],
"defaultCullingLayer": -1,
"defaultCullingDistance": [0.3, 0.3],
"offset": [0, 0.48, 0],
"initialTraits": ["Body", "Hair", "Clothing", "Head", "Face Other", "Clips and Kanzashi", "Neck", "Masks", "Glasses", "Hats", "Head Accessory Other", "Hair Accessory Other", "Ribbons and Bows", "Earring", "Wings", "Halos", "Tail"],
"traits": [],
"textureCollections": []
}

for attribute in data["attributes"]:
trait_type = attribute["trait_type"]
trait_value = attribute["value"]
original_name = attribute.get("Original") # Get the "Original" name from the attribute
renamed_name = attribute.get("Rename")

# Use the original name if available in the CSV mapping, otherwise use the trait_value
display_name = name_mapping.get(original_name, trait_value)

trait_entry = {
"trait": trait_type,
"name": trait_type.capitalize(),
"icon": "",
"type": "mesh",
"iconGradient": "",
"iconSvg": f"{trait_type.upper()}.svg",
"cullingLayer": culling_layer_mapping.get(trait_type, -1),
"cameraTarget": {"distance": 5, "height": 1.2},
"cullingDistance": [0.03, 0.03] if trait_type =="Body" else [0.3, 0.3],
"collection": [
{
"id": trait_value,
"name": trait_value,
"directory": "BODY/male.vrm" if trait_type == "Body" else f"{folder_name}/{get_id_from_mapping(renamed_name or trait_value, id_mapping)}.vrm",
"thumbnail": "BODY/male.png" if trait_type == "Body" else f"{folder_name}/thumbnails/{get_id_from_mapping(renamed_name or trait_value, id_mapping)}.png",
"textureCollection": "Body Skin" if trait_type == "Body" else ""
}
]
}

manifest["traits"].append(trait_entry)

# Append textureCollections for "BODY" trait
body_collection = {
"trait": "Body Skin",
"collection": [
{
"id": f"skin_{folder_name}",
"name": f"Eyes {folder_name}",
"directory": f"{folder_name}/skin_{folder_name}.png",
"thumbnail": f"{folder_name}/skin_{folder_name}.png"
}
]
}
manifest["textureCollections"].append(body_collection)

with open(output_file, 'w') as output:
json.dump(manifest, output, indent=2)

if __name__ == '__main__':
directory_path = "/home/jin/repo/anata-vrm/"
parser = argparse.ArgumentParser(description="Generate *_manifest.json file from *_attributes.json files")
parser.add_argument("input_file", help="Input JSON file (*_attributes.json)")
parser.add_argument("csv_file", help="Input CSV file with name mapping")
args = parser.parse_args()
id_mapping = read_csv_mapping(args.csv_file)

create_manifest(args.input_file, args.csv_file, id_mapping) # Include id_mapping argument

Can run on a folder containing JSON files from the chain like this:

for i in *.json; do python3 generate_manifest.py "$i" master_renamed_filenames.csv; done