AS11 - Anime Studio Document Format Scripting

Moho allows users to write new tools and plugins. Discuss scripting ideas and problems here.

Moderators: Víctor Paredes, Belgarath, slowtiger

Post Reply
emartin
Posts: 128
Joined: Wed Aug 24, 2011 7:47 pm
Location: Orlando, FL

AS11 - Anime Studio Document Format Scripting

Post by emartin »

This isn't a post about LUA scripting, but is a description of what you can do with scripting environments like Python and Ruby to potentially make recurring or batch changes to a document or documents. First up though, let me give you some background...

Anime Studio 11 uses a new document file format with a .anime extension. While the file may look binary if you opened it in a text editor like Notepad or TextEdit, the document is just a Zip archive with a .anime extension. Other popular document formats like Microsoft Office (e.g. .docx, .xlsx) and Comic Book files (e.g. .cbr, .cbz) make use of this same mechanism. The main document file and supporting data are compressed within an archive. If you were to expand the .anime file using something like StuffIt Expander, WinZip, or WinRar you would find either 1 or 2 files:
  • Project.animeproj
  • preview.jpg
The first file is the main project file of the document. That file is in a new JSON format. JSON is a lot more flexible than the legacy .anme format because there is structure to it. That means if we add new functionality in version 12.0 in Anime, there is a good chance you will still be able to open it up in version 11.0, because Anime Studio 11.0 can ignore any properties of the format it doesn't know about.

If you opened Project.animeproj in a text editor, you'd see all the descriptive information of the document including layers, asset paths, styles, layer comp definitions, and project settings (e.g. FPS, resolution). While the file itself is in a condensed form with no whitespace, there are various JSON utilities out there to "beautify" this JSON automatically.

The second file in the archive is a preview image of the document. If you choose not to save document thumbnails with your document (See the document preferences), this file should not exist.

Now that the document format uses open standards like Zip and JSON, you can use scripting languages like Python and Ruby to browse and manipulate the document without ever having to open the .animeproj file in a text editor. Unfortunately LUA doesn't seem to support either without adding extra support from third parties.

To show you how easy it is to browse and manipulate a document, in the code section below, there is a python script that has a number of different functions. It can list the paths to image and audio layers:

python anime_util.py list /path/to/MyScene.anime

and it can do a simple search and replace within that file:

python anime_util.py update "/Users/Bob/Content/" "" /path/to/MyScene.anime

The previous would remove path references to the /Users/Bob/Content, essentially making the path relative. To extract the .animeproj file and name it MyScene.animeproj:

python anime_util.py extract /path/to/MyScene.anime MyScene.animeproj

To use the script, just save the following code to anime_util.py. In 11.1, this script will be shipped with Anime Studio in a "Utility" scripts folder.

You can use this script as a starting point to make batch changes to a bunch of files. For example if you have 40 document files that all reference the same asset, you could potentially rename all of them without having to open each of the documents in Anime Studio. Or you could use this script as a starting point to search all .anime files that reference a particular asset. The only disclaimer I have is that any document scripting that you do can irrevocably break the files so they are no longer readable. Backups of files before running a script is mandatory.

Code: Select all

#!/usr/bin/env python
"""anime_util - Manipulate a .anime file

   Commands are list, extract, and update.
   list prints the paths of image and audio layers.
   extract saves the Project.animeproj to a desired location.
   update replaces existing paths with the one supplied.
   
   anime_util.py -h |command| for help.
"""

import zipfile
import json
import argparse
import os
import sys

def extractProject(animeFile):
    """Return the Project.animeproj extracted from a zip file as a string."""
    zf = zipfile.ZipFile(animeFile)
    for filename in [ 'Project.animeproj' ]:
        data = zf.read(filename)

    return data

def writeProject(animeFile, projectData):
    """Write data back into Project.animeproj in a zip file"""
    zf = zipfile.ZipFile(animeFile, mode="w")
    zf.writestr('Project.animeproj', projectData)

def printLayerInfo(layer):
    """Print the path of an image or audio layer."""
    if layer.has_key('image_path'):
        print "image:", layer['image_path']
    if layer.has_key('audio_path'):
        print "audio:", layer['audio_path']

def updateLayerInfo(layer, update):
    """Replace the path of an image or audio layer."""
    if layer.has_key('image_path'):
        path = layer['image_path'] 
        layer['image_path'] = path.replace(update[0], update[1])
    if layer.has_key('audio_path'):
        path = layer['audio_path'] 
        layer['audio_path'] = path.replace(update[0], update[1])

def isContainerLayer(layer):
    """Return True if the layer is a group or switch layer."""
    type = layer['type']
    return type == "GroupLayer" or type == "SwitchLayer"
    
def iterateContainerLayer(groupLayer, update=None):
    """Iterate a group or switch layer descending into sub groups and updating or printing layers otherwise."""
    for layer in groupLayer:
        if isContainerLayer(layer):
            iterateContainerLayer(layer['layers'], update)
        else:
            if update:
                updateLayerInfo(layer, update)
            else:
                printLayerInfo(layer)

def iterateLayers(path, update=None):
    """Iterate the root project layers."""
    projectPath = path
    projectData = extractProject(projectPath)

    if len(projectData) > 0:
        jsonData = json.loads(projectData)

        layers = jsonData['layers']

        iterateContainerLayer(layers, update)
        
        if update:
            writeProject(projectPath, json.dumps(jsonData))
            iterateContainerLayer(layers)

def extractProjectFile(animeFile, projectFilepath = None):
    """Iterate Project.animeproj to projectFilePath."""
    jsonData = extractProject(animeFile)
    
    if len(jsonData) > 0:
        jsonData = json.loads(jsonData)
        
        if projectFilepath:
            output = file(projectFilepath, 'wb')
        else:
            output = file('Project.animeproj', 'wb')
        json.dump(jsonData, output, sort_keys=False, indent=4)
    else:
        parser.error("Project.animeproj is empty")
        
def perform():
    parser = argparse.ArgumentParser(description="Open a .anime file and print and/or update the paths of image and audio layers")
    
    subParsers = parser.add_subparsers(help="commands", dest="command")
    parser_list = subParsers.add_parser("list", help="list image and audio paths in Anime Studio file (.anime).")
    parser_list.add_argument('path', help="path to Anime Studio file")

    parser_extract = subParsers.add_parser("extract", help="extract Project.animeproj from Anime Studio file (.anime).")
    parser_extract.add_argument('path', help="path to Anime Studio file")
    parser_extract.add_argument('projectFile', metavar='project file', help="path to save Anime Studio project file")
    parser_extract.add_argument('-b', '--beautify', action="store_true", help="beautify the project file")

    parser_update = subParsers.add_parser("update", help="update image and audio paths in Anime Studio file (.anime).")
    parser_update.add_argument('existing', help="existing path(s) in file")
    parser_update.add_argument('new', help="path to replace existing path(s) with")
    parser_update.add_argument('path', help="path to Anime Studio file")
    
    args = parser.parse_args()

    path = os.path.abspath(args.path)

    if not os.path.exists(path):
        parser.error("%s does not exist." % path)

    if not path.endswith(".anime"):
        parser.error("%s is not an Anime Studio (.anime) file." % path)

    updateInfo = None
    if args.command == "update":
        updateInfo = (args.existing, args.new)

    if args.command == "extract":
        extractProjectFile(path, os.path.abspath(args.projectFile))
    else:
        iterateLayers(path, update=updateInfo)

if __name__ == '__main__':
    perform()
Happy Scripting,
Erik
Last edited by emartin on Wed Jun 03, 2015 10:41 pm, edited 2 times in total.
emartin
Posts: 128
Joined: Wed Aug 24, 2011 7:47 pm
Location: Orlando, FL

Re: AS11 - Anime Studio Document Format Scripting

Post by emartin »

Here is some more general information about the document file format:

Project.animeproj generally follows the flow of the legacy .anme format:
  • Header information (format version, created date, comment)
  • Project Settings (start frame, end frame, background color, etc)
  • Document thumbnail (optional)
  • Styles (if any)
  • Layer Comps (if any)
  • Document Metadata
  • Document level camera data and Timeline Markers
  • Layer list (this will likely be the biggest chunk of a document)
All the potential layers and their values would be huge to describe as I count there is currently 12 different types of layers:
  • UnknownLayer
  • MeshLayer (aka Vector layer)
  • ImageLayer
  • GroupLayer
  • BoneLayer
  • SwitchLayer
  • ParticleLayer
  • NoteLayer
  • Mesh3DLayer
  • AudioLayer
  • PatchLayer
  • TextLayer
You can search the document for individual layer types by searching for something like "ImageLayer" or "BoneLayer" (the JSON key for the layer type would be named "type") and than searching forward for it's "name" key (e.g. "Image 1")

Layers have their own layer specific properties (e.g. "image_path") and general properties that apply to all layers (e.g. "name", "origin", and "visible"). Animatable properties of a layer are typically of a certain type like "Vec2" (2D Vector), "Color", or "String". These properties usually include the keyframes of "when" they happen on the timeline as well as interpolation settings.

Vector layer's are unique in that they can be described by a number of complex defined properties:
  • Point
  • PointGroup
  • Curve
  • Shape
  • Bone
  • Skeleton
  • Mesh
  • Mesh3D
  • Face3D
  • Style
And finally Style types can be one of this list:
  • SS_AngledPen
  • SS_Crayon
  • SS_Gradient
  • SS_Gradient2
  • SS_Halo
  • SS_Shaded
  • SS_Shadow
  • SS_Sketchy
  • SS_Soft
  • SS_Splotchy
  • SS_Spots
  • SS_Texture
  • SS_Texture2
Erik
emartin
Posts: 128
Joined: Wed Aug 24, 2011 7:47 pm
Location: Orlando, FL

Re: AS11 - Anime Studio Document Format Scripting

Post by emartin »

This script is similar to the post above in that it can extract the Project.animeproj. The difference is this one renames the extracted file to an extension of .json so that it can easily be opened in a JSON editor:

For the more technically minded who can use a command-line, below is some python code to simplify extracting the JSON from the .anime file. For example, if you have a document named MyScene.anime, the resulting file would be MyScene.json in the same folder. You can than edit the file in your favorite JSON editor (Notepad, Notepad++, TextEdit, Xcode, etc).

Here is how to make the following code work:
  • You must have Python 2.7+ installed and have the python directory in the PATH (python is already installed on Macs)
  • Cut and paste the code into a python file (e.g. extract_to_json.py)
  • Call this from the Command Prompt/Terminal: python extract_to_json.py MyScene.anime
  • A file will be created named MyScene.json in the same folder as the .anime file
Note that there is no file conflict handling to this script. So if you already have a MyScene.json in the same folder as the .anime file, the script will overwrite the file. This script also takes wildcards, so you can do something like this:

python extract_to_json.py *.anime

Those with Python chops can use this script as a template to essentially do the reverse. Take the edited JSON file and recreate the .anime file. The main logic is in ExtractToJSON.

Code: Select all

import os, os.path, json, shutil, sys, zipfile

def ExtractToJSON(animepath, beautify = False):
	if (not os.path.exists(animepath)):
		print 'ERROR: %s does not exist' % animepath
		return
	
	# Get the folder and the file name for the path
	folder, animefilename = os.path.split(animepath)
	print 'Extracting from ' + animefilename + '...'

	# Open the .anime file
	with zipfile.ZipFile(animepath) as zip_file:
		# Iterate over all of the items in the zip file
		for item in zip_file.namelist():
			filename = os.path.basename(item)
			
			# skip everything except the .animeproj file, which is JSON
			if filename != 'Project.animeproj':
				continue

			# Create the extracted .json path name
			filename, ext = os.path.splitext(animefilename)
			filename += '.json'
			jsonpath = os.path.join(folder, filename)

			# Load the JSON from the zip file and dump it beautified to the .json path
			source = zip_file.open(item)
			if (beautify):
				json_obj = json.load(source)
				json.dump(json_obj, file(jsonpath, "wb"), sort_keys=False)
			else:
				shutil.copyfileobj(source, file(jsonpath, "wb"))


def Usage():
	print '%s <.anime files> - Extract .anime to JSON' % os.path.basename(sys.argv[0])

def Main():
	numArgs = len(sys.argv)
	if numArgs == 1:
		Usage()
		return
	
	for animepath in sys.argv[1:]:
		ExtractToJSON(os.path.normpath(animepath))


if __name__ == '__main__':
	Main()
JeroenKoffeman
Posts: 32
Joined: Tue Mar 24, 2015 3:04 pm

Re: AS11 - Anime Studio Document Format Scripting

Post by JeroenKoffeman »

Thanks for these scripts!

I found a small problem in anim util.py, easily fixed:

in ln 51:
return type == "GroupLayer" or type == "SwitchLayer"

add:

or type == "BoneLayer"

So it will also search through Bone layers.


Also I made a Batch anime Util. Instead of looking through a file, it will search and update all .anime files within a folder. Everything else works the same as anime Util. To make it work, Simply specify the folder instead of the .anime file in cmd.

Code: Select all

#!/usr/bin/env python

"""anime_util - Manipulate a .anime file

   Commands are list, extract, and update.
   list prints the paths of image and audio layers.
   extract saves the Project.animeproj to a desired location.
   update replaces existing paths with the one supplied.
   
   anime_util.py -h |command| for help.
"""

import zipfile
import json
import argparse
import os
import sys
import glob

def extractProject(animeFile):
    """Return the Project.animeproj extracted from a zip file as a string."""
    zf = zipfile.ZipFile(animeFile)
    for filename in [ 'Project.animeproj' ]:
        data = zf.read(filename)

    return data

def writeProject(animeFile, projectData):
    """Write data back into Project.animeproj in a zip file"""
    zf = zipfile.ZipFile(animeFile, mode="w")
    zf.writestr('Project.animeproj', projectData)

def printLayerInfo(layer):
    """Print the path of an image or audio layer."""
    if layer.has_key('image_path'):
        print "image:", layer['image_path']
    if layer.has_key('audio_path'):
        print "audio:", layer['audio_path']

def updateLayerInfo(layer, update):
    """Replace the path of an image or audio layer."""
    if layer.has_key('image_path'):
        path = layer['image_path'] 
        layer['image_path'] = path.replace(update[0], update[1])
    if layer.has_key('audio_path'):
        path = layer['audio_path'] 
        layer['audio_path'] = path.replace(update[0], update[1])

def isContainerLayer(layer):
    """Return True if the layer is a group or switch layer."""
    type = layer['type']
    return type == "GroupLayer" or type == "SwitchLayer" or type == "BoneLayer"
    
def iterateContainerLayer(groupLayer, update=None):
    """Iterate a group or switch layer descending into sub groups and updating or printing layers otherwise."""
    for layer in groupLayer:
        if isContainerLayer(layer):
            iterateContainerLayer(layer['layers'], update)
        else:
            if update:
                updateLayerInfo(layer, update)
            else:
                printLayerInfo(layer)

def iterateLayers(path, update=None):
    """Iterate the root project layers."""
    projectPath = path
    projectData = extractProject(projectPath)

    if len(projectData) > 0:
        jsonData = json.loads(projectData)

        layers = jsonData['layers']

        iterateContainerLayer(layers, update)
        
        if update:
            writeProject(projectPath, json.dumps(jsonData))
            iterateContainerLayer(layers)

def extractProjectFile(animeFile, projectFilepath = None):
    """Iterate Project.animeproj to projectFilePath."""
    jsonData = extractProject(animeFile)
    
    if len(jsonData) > 0:
        jsonData = json.loads(jsonData)
        
        if projectFilepath:
            output = file(projectFilepath, 'wb')
        else:
            output = file('Project.animeproj', 'wb')
        json.dump(jsonData, output, sort_keys=False, indent=4)
    else:
        parser.error("Project.animeproj is empty")
        
def perform():
    parser = argparse.ArgumentParser(description="Open a .anime file and print and/or update the paths of image and audio layers")
    
    subParsers = parser.add_subparsers(help="commands", dest="command")
    parser_list = subParsers.add_parser("list", help="list image and audio paths in Anime Studio file (.anime).")
    parser_list.add_argument('path', help="path to Anime Studio file")

    parser_extract = subParsers.add_parser("extract", help="extract Project.animeproj from Anime Studio file (.anime).")
    parser_extract.add_argument('path', help="path to Anime Studio file")
    parser_extract.add_argument('projectFile', metavar='project file', help="path to save Anime Studio project file")
    parser_extract.add_argument('-b', '--beautify', action="store_true", help="beautify the project file")

    parser_update = subParsers.add_parser("update", help="update image and audio paths in Anime Studio file (.anime).")
    parser_update.add_argument('existing', help="existing path(s) in file")
    parser_update.add_argument('new', help="path to replace existing path(s) with")
    parser_update.add_argument('path', help="path to Anime Studio file")
    
    args = parser.parse_args()

    path = os.path.abspath(args.path)

    if not os.path.exists(path):
        parser.error("%s does not exist." % path)



    
					
		

				
    if not path.endswith(".anime"):
		for file in os.listdir(path):
			if file.endswith(".anime"):
				print(file)				


				updateInfo = None
				if args.command == "update":
					updateInfo = (args.existing, args.new)

				if args.command == "extract":
					extractProjectFile(path, os.path.abspath(args.projectFile))
				else:
					iterateLayers(path+"/"+file, update=updateInfo)

if __name__ == '__main__':
    perform()
    print "finished" 	
Post Reply