base/Project.js

/*
 * Copyright 2020 WICKLETS LLC
 *
 * This file is part of Wick Engine.
 *
 * Wick Engine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Wick Engine is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Wick Engine.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * Class representing a Wick Project.
 */
Wick.Project = class extends Wick.Base {
    /**
     * Create a project.
     * @param {string} name - Project name. Default "My Project".
     * @param {number} width - Project width in pixels. Default 720.
     * @param {number} height - Project height in pixels. Default 480.
     * @param {number} framerate - Project framerate in frames-per-second. Default 12.
     * @param {Color} backgroundColor - Project background color in hex. Default #ffffff.
     */
    constructor(args) {
        if (!args) args = {};
        super(args);

        this._name = args.name || 'My Project';
        this._width = args.width || 720;
        this._height = args.height || 480;
        this._framerate = args.framerate || 12;
        this._backgroundColor = args.backgroundColor || new Wick.Color('#ffffff');
        this._hitTestOptions = this.getDefaultHitTestOptions();

        this.pan = { x: 0, y: 0 };
        this._zoom = 1.0;
        this.rotation = 0.0;

        this._onionSkinEnabled = false;
        this.onionSkinSeekBackwards = 1;
        this.onionSkinSeekForwards = 1;

        this.selection = new Wick.Selection();
        this.history = new Wick.History();
        this.clipboard = new Wick.Clipboard();

        this.root = new Wick.Clip({ project: this });
        this.root._identifier = 'Project';

        this.focus = this.root;

        this._mousePosition = { x: 0, y: 0 };
        this._lastMousePosition = { x: 0, y: 0 };
        this._isMouseDown = false;
        this._internalErrorMessages = [];

        this.soundsPlayed = []; // List of all sounds that have been played during this play through of the project.

        this._mouseTargets = [];

        this._keysDown = [];
        this._keysLastDown = [];
        this._currentKey = null;

        this._tickIntervalID = null;

        this._hideCursor = false;
        this._muted = false;
        this._publishedMode = false; // Review the publishedMode setter for rules.
        this._showClipBorders = true;

        this._userErrorCallback = null;

        this._tools = {
            brush: new Wick.Tools.Brush(),
            cursor: new Wick.Tools.Cursor(),
            ellipse: new Wick.Tools.Ellipse(),
            eraser: new Wick.Tools.Eraser(),
            eyedropper: new Wick.Tools.Eyedropper(),
            fillbucket: new Wick.Tools.FillBucket(),
            interact: new Wick.Tools.Interact(),
            line: new Wick.Tools.Line(),
            none: new Wick.Tools.None(),
            pan: new Wick.Tools.Pan(),
            pathcursor: new Wick.Tools.PathCursor(),
            pencil: new Wick.Tools.Pencil(),
            rectangle: new Wick.Tools.Rectangle(),
            text: new Wick.Tools.Text(),
            zoom: new Wick.Tools.Zoom(),
        };

        for (var toolName in this._tools) {
            this._tools[toolName].project = this;
        }

        this.activeTool = 'cursor';

        this._toolSettings = new Wick.ToolSettings();
        this._toolSettings.onSettingsChanged((name, value) => {
            if (name === 'fillColor') {
                this.selection.fillColor = value.rgba;
            } else if (name === 'strokeColor') {
                this.selection.strokeColor = value.rgba;
            }
        });

        this._playing = false;

        this._scriptSchedule = [];
        this._error = null;

        this.history.project = this;
        this.history.pushState(Wick.History.StateType.ONLY_VISIBLE_OBJECTS);
    }

    /**
     * Prepares the project to be used in an editor.
     */
    prepareProjectForEditor () {
        this.project.resetCache();
        this.project.recenter();
        this.project.view.prerender();
        this.project.view.render();
    }

    /**
     * Used to initialize the state of elements within the project. Should only be called after
     * deserialization of project and all objects within the project.
     */
    initialize () {
        // Fixing all clip positions... This should be done in an internal method when the project is done loading...
        this.activeFrame && this.activeFrame.clips.forEach(clip => {
            clip.applySingleFramePosition();
        });
    }

    /**
     * Resets the cache and removes all unlinked items from the project.
     */
    resetCache () {
      Wick.ObjectCache.removeUnusedObjects(this);
    }

    /**
     * TODO: Remove all elements created by this project.
     */
    destroy () {
        this.guiElement.removeAllEventListeners();
    }

    _deserialize (data) {
        super._deserialize(data);

        this.name = data.name;
        this.width = data.width;
        this.height = data.height;
        this.framerate = data.framerate;
        this.backgroundColor = new Wick.Color(data.backgroundColor);

        this._focus = data.focus;

        this._hideCursor = false;
        this._muted = false;
        this._renderBlackBars = true;

        this._hitTestOptions = this.getDefaultHitTestOptions();

        // reset rotation, but not pan/zoom.
        // not resetting pan/zoom is convenient when preview playing.
        this.rotation = 0;
    }

    _serialize(args) {
        var data = super._serialize(args);

        data.name = this.name;
        data.width = this.width;
        data.height = this.height;
        data.backgroundColor = this.backgroundColor.rgba;
        data.framerate = this.framerate;

        data.onionSkinEnabled = this.onionSkinEnabled
        data.onionSkinSeekForwards = this.onionSkinSeekForwards;
        data.onionSkinSeekBackwards = this.onionSkinSeekBackwards;

        data.focus = this.focus.uuid;

        // Save some metadata which will eventually end up in the wick file
        data.metadata = Wick.WickFile.generateMetaData();

        return data;
    }

    getDefaultHitTestOptions() {
        return {mode: 'RECTANGLE', offset: true, overlap: true, intersections: false};
    }

    get classname() {
        return 'Project';
    }

    /**
     * Assign a function to be called when a user error happens (not script
     * errors - errors such as drawing tool errors, invalid selection props, etc)
     * @param {Function} fn - the function to call when errors happen
     */
    onError (fn) {
        this._userErrorCallback = fn;
    }

    /**
     * Called when an error occurs to forward to the onError function
     * @param {String} message - the message to display for the error
     */
    errorOccured (message) {
        if (this._userErrorCallback) this._userErrorCallback(message);
        this._internalErrorMessages.push(message);
    }

    /**
     * The width of the project.
     * @type {number}
     */
    get width() {
        return this._width;
    }

    set width(width) {
        if (typeof width !== 'number') return;
        if (width < 1) width = 1;
        if (width > 200000) width = 200000;
        this._width = width;
    }

    /**
     * The height of the project.
     * @type {number}
     */
    get height() {
        return this._height;
    }

    set height(height) {
        if (typeof height !== 'number') return;
        if (height < 1) height = 1;
        if (height > 200000) height = 200000;
        this._height = height;
    }

    /**
     * The framerate of the project.
     * @type {number}
     */
    get framerate() {
        return this._framerate;
    }

    set framerate(framerate) {
        if (typeof framerate !== 'number') return;
        if (framerate < 1) framerate = 1;
        if (framerate > 9999) framerate = 9999;
        this._framerate = framerate;
    }

    /**
     * The background color of the project.
     * @type {string}
     */
    get backgroundColor() {
        return this._backgroundColor;
    }

    set backgroundColor(backgroundColor) {
        this._backgroundColor = backgroundColor;
    }

    get hitTestOptions() {
        return this._hitTestOptions;
    }

    set hitTestOptions(options) {
        if (options) {
            if (options.mode === 'CIRCLE' || options.mode === 'RECTANGLE' || options.mode === 'CONVEX') {
                this._hitTestOptions.mode = options.mode;
            }
            if (typeof options.offset === 'boolean') {
                this._hitTestOptions.offset = options.offset;
            }
            if (typeof options.overlap === 'boolean') {
                this._hitTestOptions.overlap = options.overlap;
            }
            if (typeof options.intersections === 'boolean') {
                this._hitTestOptions.intersections = options.intersections;
            }
        }
    }

    /**
     * The timeline of the active clip.
     * @type {Wick.Timeline}
     */
    get activeTimeline() {
        return this.focus.timeline;
    }

    /**
     * The active layer of the active timeline.
     * @type {Wick.Layer}
     */
    get activeLayer() {
        return this.activeTimeline.activeLayer;
    }

    /**
     * The active frame of the active layer.
     * @type {Wick.Frame}
     */
    get activeFrame() {
        return this.activeLayer.activeFrame;
    }

    /**
     * The active frames of the active timeline.
     * @type {Wick.Frame[]}
     */
    get activeFrames() {
        return this.focus.timeline.activeFrames;
    }

    /**
     * All frames in this project.
     * @type {Wick.Frame[]}
     */
    getAllFrames() {
        return this.root.timeline.getAllFrames(true);
    }

    /**
     * The project selection.
     * @type {Wick.Selection}
     */
    get selection() {
        return this.getChild('Selection');
    }

    set selection(selection) {
        if (this.selection) {
            this.removeChild(this.selection);
        }
        this.addChild(selection);
    }

    /**
     * An instance of the Wick.History utility class for undo/redo functionality.
     * @type {Wick.History}
     */
    get history() {
        return this._history;
    }

    set history(history) {
        this._history = history;
    }

    /**
     * Value used to determine the zoom of the canvas.
     */
    get zoom () {
        return this._zoom;
    }

    set zoom(z) {
        const max = this.view.calculateFitZoom() * 10;
        const min = .10;
        this._zoom = Math.max(min, Math.min(max, z));
    }

    /**
     * Undo the last action.
     * @returns {boolean} true if there was something to undo, false otherwise.
     */
    undo() {
        // Undo discards in-progress brush strokes.
        if (this._tools.brush.isInProgress()) {
            this._tools.brush.discard();
            return true;
        }

        this.selection.clear();
        var success = this.project.history.popState();
        return success;
    }

    /**
     * Redo the last action that was undone.
     * @returns {boolean} true if there was something to redo, false otherwise.
     */
    redo() {
        this.selection.clear();
        var success = this.project.history.recoverState();
        return success;
    }

    /**
     * The assets belonging to the project.
     * @type {Wick.Asset[]}
     */
    get assets() {
        return this.getChildren(['ImageAsset', 'SoundAsset', 'ClipAsset', 'FontAsset', 'SVGAsset']);
    }

    /**
     * Adds an asset to the project.
     * @param {Wick.Asset} asset - The asset to add to the project.
     */
    addAsset(asset) {
        if (this.assets.indexOf(asset) === -1) {
            this.addChild(asset);
        }
    }

    /**
     * Removes an asset from the project. Also removes all instances of that asset from the project.
     * @param {Wick.Asset} asset - The asset to remove from the project.
     */
    removeAsset(asset) {
        asset.removeAllInstances();
        this.removeChild(asset);
    }

    /**
     * Retrieve an asset from the project by its UUID.
     * @param {string} uuid - The UUID of the asset to get.
     * @return {Wick.Asset} The asset
     */
    getAssetByUUID(uuid) {
        var asset = this.getAssets().find(asset => {
            return asset.uuid === uuid;
        });

        if (asset) {
            return asset;
        } else {
            console.warn('Wick.Project.getAssetByUUID: No asset found with uuid ' + uuid);
        }
    }

    /**
     * Retrieve an asset from the project by its name.
     * @param {string} name - The name of the asset to get.
     * @return {Wick.Asset} The asset
     */
    getAssetByName(name) {
        return this.getAssets().find(asset => {
            return asset.name === name;
        });
    }

    /**
     * The assets belonging to the project.
     * @param {string} type - Optional, filter assets by type ("Sound"/"Image"/"Clip"/"Button")
     * @returns {Wick.Asset[]} The assets in the project
     */
    getAssets(type) {
        if (!type) {
            return this.assets;
        } else {
            return this.assets.filter(asset => {
                return asset instanceof Wick[type + 'Asset'];
            });
        }
    }

    /**
     * A list of all "fontFamily" in the asset library.
     * @returns {string[]}
     */
    getFonts() {
        return this.getAssets('Font').map(asset => {
            return asset.fontFamily;
        });
    }

    /**
     * Check if a FontAsset with a given fontFamily exists in the project.
     * @param {string} fontFamily - The font to check for
     * @returns {boolean}
     */
    hasFont(fontFamily) {
        return this.getFonts().find(seekFontFamily => {
            return seekFontFamily === fontFamily;
        }) !== undefined;
    }

    /**
     * The root clip.
     * @type {Wick.Clip}
     */
    get root() {
        return this.getChild('Clip');
    }

    set root(root) {
        if (this.root) {
            this.removeChild(this.root);
        }
        this.addChild(root);
    }

    /**
     * The currently focused clip.
     * @type {Wick.Clip}
     */
    get focus() {
        return this._focus && Wick.ObjectCache.getObjectByUUID(this._focus);
    }

    set focus(focus) {
        var focusChanged = this.focus !== null && this.focus !== focus;

        this._focus = focus.uuid;

        if (focusChanged) {
            this.selection.clear();

            // Reset timelines of subclips of the newly focused clip
            focus.timeline.clips.forEach(subclip => {
                subclip.timeline.playheadPosition =  1;
                subclip.applySingleFramePosition(); // Make sure to visualize single frame clips properly.
            });

            // Reset pan and zoom and clear selection on focus change
            this.resetZoomAndPan();
        } else {
            // Make sure the single frame
            focus.timeline.clips.forEach(subclip => {
                subclip.applySingleFramePosition();
            });
        }
    }

    /**
     * The position of the mouse
     * @type {object}
     */
    get mousePosition() {
        return this._mousePosition;
    }

    set mousePosition(mousePosition) {
        this._lastMousePosition = { x: this.mousePosition.x, y: this.mousePosition.y };
        this._mousePosition = mousePosition;
    }

    /**
     * The amount the mouse has moved in the last tick
     * @type {object}
     */
    get mouseMove() {
        let moveX = this.mousePosition.x - this._lastMousePosition.x;
        let moveY = this.mousePosition.y - this._lastMousePosition.y;
        return { x: moveX, y: moveY };
    }

    /**
     * Determine if the mouse is down.
     * @type {boolean}
     */
    get isMouseDown() {
        return this._isMouseDown;
    }

    set isMouseDown(isMouseDown) {
        this._isMouseDown = isMouseDown;
    }

    /**
     * The keys that are currenty held down.
     * @type {string[]}
     */
    get keysDown() {
        return this._keysDown;
    }

    set keysDown(keysDown) {
        this._keysDown = keysDown;
    }

    /**
     * The keys were just pressed (i.e., are currently held down, but were not last tick).
     * @type {string[]}
     */
    get keysJustPressed() {
        // keys that are in _keysDown, but not in _keysLastDown
        return this._keysDown.filter(key => {
            return this._keysLastDown.indexOf(key) === -1;
        });
    }

    /**
     * The keys that were just released (i.e. were down last tick back are no longer down.)
     * @return {string[]}
     */
    get keysJustReleased() {
        return this._keysLastDown.filter(key => {
            return this._keysDown.indexOf(key) === -1;
        });
    }

    /**
     * Check if a key is being pressed.
     * @param {string} key - The name of the key to check
     */
    isKeyDown(key) {
        return this.keysDown.indexOf(key) !== -1;
    }

    /**
     * Check if a key was just pressed.
     * @param {string} key - The name of the key to check
     */
    isKeyJustPressed(key) {
        return this.keysJustPressed.indexOf(key) !== -1;
    }

    /**
     * The key to be used in the global 'key' variable in the scripting API. Update currentKey before you run any key script.
     * @type {string[]}
     */
    get currentKey() {
        return this._currentKey;
    }

    set currentKey(currentKey) {
        this._currentKey = currentKey;
    }

    /**
     * Creates an asset from a File object and adds that asset to the project.
     * @param {File} file - File object to be read and converted into an asset.
     * @param {function} callback Function with the created Wick Asset. Can be passed undefined on improper file input.
     */
    importFile(file, callback) {
        let imageTypes = Wick.ImageAsset.getValidMIMETypes();
        let soundTypes = Wick.SoundAsset.getValidMIMETypes();
        let fontTypes = Wick.FontAsset.getValidMIMETypes();
        let clipTypes = Wick.ClipAsset.getValidMIMETypes();
        let svgTypes = Wick.SVGAsset.getValidMIMETypes();

        let imageExtensions = Wick.ImageAsset.getValidExtensions();
        let soundExtensions = Wick.SoundAsset.getValidExtensions();
        let fontExtensions = Wick.FontAsset.getValidExtensions();
        let clipExtensions = Wick.ClipAsset.getValidExtensions();
        let svgExtensions = Wick.SVGAsset.getValidExtensions();

        // Fix missing mimetype for wickobj files
        var type = file.type;
        
        if (file.type === '' && file.name.endsWith('.wickobj')) {
            type = 'application/json';
        }
        
        var extension = "";
        
        if (file.name) {
            extension = file.name.split('.').pop();
        } else if (file.file && typeof file.file === 'string') {
            extension = file.file.split('.').pop();
        }

        let asset = undefined;
        if (imageTypes.indexOf(type) !== -1 || imageExtensions.indexOf(extension) !== -1) {
            asset = new Wick.ImageAsset();
        } else if (soundTypes.indexOf(type) !== -1 || soundExtensions.indexOf(extension) !== -1) {
            asset = new Wick.SoundAsset();
        } else if (fontTypes.indexOf(type) !== -1 || fontExtensions.indexOf(extension) !== -1) {
            asset = new Wick.FontAsset();
        } else if (clipTypes.indexOf(type) !== -1 || clipExtensions.indexOf(extension) !== -1) {
            asset = new Wick.ClipAsset();
        } else if (svgTypes.indexOf(type) !== -1 || svgExtensions.indexOf(extension) !== -1) {
            asset = new Wick.SVGAsset();
        }

        if (asset === undefined) {
            console.warn('importFile(): Could not import file ' + file.name + ', filetype: "' + file.type + '" is not supported.');
            console.warn('Supported File Types Are:', {
                image: imageTypes, 
                sound: soundTypes,
                font: fontTypes,
                clip: clipTypes,
                svg: svgTypes
            });
            console.warn('Supported File Extensions Are', {
                image: imageExtensions,
                sound: soundExtensions,
                font: fontExtensions,
                clip: clipExtensions,
                svg: svgExtensions,
            })
            callback(null);
            return;
        }

        let reader = new FileReader();

        reader.onload = () => {
            let dataURL = reader.result;
            asset.src = dataURL;
            asset.filename = file.name;
            asset.name = file.name;
            this.addAsset(asset);
            asset.load(() => {
                callback(asset);
            });
        }

        reader.readAsDataURL(file);
    }

    /**
     * True if onion skinning is on. False otherwise.
     */
    get onionSkinEnabled () {
        return this._onionSkinEnabled;
    }

    set onionSkinEnabled (bool) {
        if (typeof bool !== "boolean") return;

        // Get all onion skinned frames, if we're turning off onion skinning.
        let onionSkinnedFrames = [];
        if (!bool) onionSkinnedFrames = this.getAllOnionSkinnedFrames();

        this._onionSkinEnabled = bool;

        // Rerender any onion skinned frames.
        onionSkinnedFrames.forEach(frame => {
            frame.view.render();
        });
    }

    /**
     * Returns all frames that should currently be onion skinned.
     * @returns {Wick.Frame[]} Array of Wick frames that sould be onion skinned.
     */
    getAllOnionSkinnedFrames () {
        let onionSkinnedFrames = [];
        this.activeTimeline.layers.forEach(layer => {
            let frames = layer.frames.filter(frame => {
                return frame.onionSkinned;
            });

            onionSkinnedFrames = onionSkinnedFrames.concat(frames);
        });

        return onionSkinnedFrames;
    }

    /**
     * Deletes all objects in the selection.
     */
    deleteSelectedObjects() {
        var objects = this.selection.getSelectedObjects();

        this.selection.clear();

        this.activeTimeline.deferFrameGapResolve();
        objects.forEach(object => {
            if (object.remove) {
                object.remove();
            }
            else if (['ImageAsset', 'SoundAsset', 'ClipAsset', 'FontAsset', 'SVGAsset'].indexOf(object.classname) !== -1) {
                this.removeAsset(object);
            }
        });
        this.activeTimeline.resolveFrameGaps([]);
    }

    /**
     * Perform a boolean operation on all selected paths.
     * @param {string} booleanOpName - The name of the boolean op function to use. See Wick.Path.booleanOp.
     */
    doBooleanOperationOnSelection(booleanOpName) {
        var paths = this.selection.getSelectedObjects('Path');
        this.selection.clear();
        var booleanOpResult = Wick.Path.booleanOp(paths, booleanOpName);

        paths.forEach(path => {
            // Don't remove the topmost path if performing subtration
            if (paths.indexOf(path) === paths.length - 1 && booleanOpName === 'subtract') {
                return;
            }
            path.remove();
        });

        this.activeFrame.addPath(booleanOpResult);
        this.selection.select(booleanOpResult);
    }

    /**
     * Copy the contents of the selection to the clipboard.
     * @returns {boolean} True if there was something to copy, false otherwise
     */
    copySelectionToClipboard() {
        var objects = this.selection.getSelectedObjects();
        if (objects.length === 0) {
            return false;
        } else {
            this.clipboard.copyObjectsToClipboard(this, objects);
            return true;
        }
    }

    /**
     * Copy the contents of the selection to the clipboard, and delete what was copied.
     * @returns {boolean} True if there was something to cut, false otherwise
     */
    cutSelectionToClipboard() {
        if (this.copySelectionToClipboard()) {
            this.deleteSelectedObjects();
            return true;
        } else {
            return false;
        }
    }

    /**
     * Paste the contents of the clipboard into the project.
     * @returns {boolean} True if there was something to paste in the clipboard, false otherwise.
     */
    pasteClipboardContents() {
        return this.clipboard.pasteObjectsFromClipboard(this);
    }

    /**
     * Copy and paste the current selection.
     * @returns {boolean} True if there was something to duplicate, false otherwise
     */
    duplicateSelection() {
        if (!this.copySelectionToClipboard()) {
            return false;
        } else {
            return this.pasteClipboardContents();
        }
    }

    /**
     * Move the current selection above, below, or inside target.
     * @param {object} target - The target object (to become parent of selection)
     * @param {number} index - index to insert at
     * @returns {boolean} - true if project did change
     */
    moveSelection(target, index) {
        // Indices give us a way to order the selection from top to bottom
        let get_indices = (obj) => {
            var indices = [];
            while (obj.parent !== null) {
                let parent = obj.parent;
                if (parent.classname === 'Frame') {
                    indices.unshift(parent.getChildren().length - 1 - parent.getChildren().indexOf(obj));
                }
                else {
                    indices.unshift(parent.getChildren().indexOf(obj));
                }
                obj = parent;
            }
            return indices;
        }
        // Assumes i1, i2 same length, ordering same as outliner
        let compare_indices = (i1, i2) => {
            for (let i = 0; i < i1.length; i++) {
                if (i1[i] < i2[i]) {
                    return 1;
                }
                else if (i1[i] > i2[i]) {
                    return -1;
                }
            }
            return 0;
        }

        let selection = this.selection.getSelectedObjects()
        if (selection.length === 0) {
            return false;
        }
        let selection_indices = selection.map(get_indices);
        let l = selection_indices[0].length;
        for (let i = 0; i < selection_indices.length; i++) {
            if (selection_indices[i].length !== l) {
                // Must all have the same depth
                return false;
            }
        }
        let zip = selection_indices.map((o, i) => {return [o, selection[i]]});
        zip.sort(([i1,], [i2,]) => compare_indices(i1,i2));
        if (target.classname === 'Frame') {
            // Render order is reversed for children of frames
            zip.reverse();
        }
        for (let i = 0; i < zip.length; i++) {
            let [, obj] = zip[i];
            index -= target.insertChild(obj, index) ? 1 : 0;
        }
        return true;
    }

    /**
     * Cut the currently selected frames.
     */
    cutSelectedFrames() {
        this.selection.getSelectedObjects('Frame').forEach(frame => {
            frame.cut();
        });
    }

    /**
     * Inserts a blank frame into the timeline at the position of the playhead.
     * If the playhead is over an existing frame, that frame will be cut in half,
     * and a blank frame will be added to fill the empty space created by the cut.
     */
    insertBlankFrame() {
        var playheadPosition = this.activeTimeline.playheadPosition;
        var newFrames = [];

        // Insert new frames
        if (this.selection.numObjects > 0) {
            // Insert frames on all frames that are both active and selected
            /*this.activeTimeline.activeFrames.filter(frame => {
                return frame.isSelected;
            }).forEach(frame => {
                newFrames.push(frame.parentLayer.insertBlankFrame(playheadPosition));
            });*/
            this.selection.getSelectedObjects('Frame').forEach(frame => {
                newFrames.push(frame.parentLayer.insertBlankFrame(playheadPosition));
            });
        } else {
            // Insert one frame on the active layer
            newFrames.push(this.activeLayer.insertBlankFrame(playheadPosition));
        }

        // Select the newly added frames
        this.selection.clear();

        this.selection.selectMultipleObjects(newFrames);
    }

    /**
     * A tween can be created if frames are selected or if there is a frame under the playhead on the active layer.
     */
    get canCreateTween() {
        // Frames are selected, a tween can be created
        var selectedFrames = this.selection.getSelectedObjects('Frame');
        if (selectedFrames.length > 0) {
            // Make sure you can only create tweens on contentful frames
            if (selectedFrames.find(frame => {
                    return !frame.contentful;
                })) {
                return false
            } else {
                return true;
            }
        }

        // There is a frame under the playhead on the active layer, a tween can be created
        var activeFrame = this.activeLayer.activeFrame;
        if (activeFrame) {
            // ...but only if that frame is contentful
            return activeFrame.contentful;
        }

        return false;
    }

    /**
     * Create a new tween on all selected frames OR on the active frame of the active layer.
     */
    createTween() {
        var selectedFrames = this.selection.getSelectedObjects('Frame');
        if (selectedFrames.length > 0) {
            // Create a tween on all selected frames
            this.selection.getSelectedObjects('Frame').forEach(frame => {
                frame.createTween();
            });
        } else {
            // Create a tween on the active frame
            this.activeLayer.activeFrame.createTween();
        }
    }

    /**
     * Tries to create a tween if there is an empty space between tweens.
     */
    tryToAutoCreateTween() {
        var frame = this.activeFrame;
        if (frame.tweens.length > 0 && !frame.getTweenAtPosition(frame.getRelativePlayheadPosition())) {
            frame.createTween();
        }
    }

    /**
     * Move the right edge of all frames right one frame.
     */
    extendFrames(frames) {
        frames.forEach(frame => {
            frame.end++;
        });
        this.activeTimeline.resolveFrameOverlap(frames);
        this.activeTimeline.resolveFrameGaps(frames);
    }

    /**
     * Move the right edge of all frames right one frame, and push other frames away.
     */
    extendFramesAndPushOtherFrames(frames) {
        frames.forEach(frame => {
            frame.extendAndPushOtherFrames();
        });
    }

    /**
     * Move the right edge of all frames left one frame.
     */
    shrinkFrames(frames) {
        frames.forEach(frame => {
            if (frame.length === 1) return;
            frame.end--;
        });
        this.activeTimeline.resolveFrameOverlap(frames);
        this.activeTimeline.resolveFrameGaps(frames);
    }

    /**
     * Move the right edge of all frames left one frame, and pull other frames along.
     */
    shrinkFramesAndPullOtherFrames(frames) {
        frames.forEach(frame => {
            frame.shrinkAndPullOtherFrames();
        });
    }

    /**
     * Shift all selected frames over one frame to the right
     */
    moveSelectedFramesRight() {
        var frames = this.selection.getSelectedObjects('Frame');
        frames.forEach(frame => {
            frame.end++;
            frame.start++;
        });
        this.activeTimeline.resolveFrameOverlap(frames);
        this.activeTimeline.resolveFrameGaps();
    }

    /**
     * Shift all selected frames over one frame to the left
     */
    moveSelectedFramesLeft() {
        var frames = this.selection.getSelectedObjects('Frame');
        frames.forEach(frame => {
            frame.start--;
            frame.end--;
        });
        this.activeTimeline.resolveFrameOverlap(frames);
        this.activeTimeline.resolveFrameGaps();
    }

    /**
     * Selects all objects that are visible on the canvas (excluding locked layers and onion skinned objects)
     */
    selectAll() {
        let objectsToAdd = [];

        this.selection.clear();
        this.activeFrames.filter(frame => {
            return !frame.parentLayer.locked &&
                !frame.parentLayer.hidden;
        }).forEach(frame => {
            frame.paths.forEach(path => {
                objectsToAdd.push(path);
            });
            frame.clips.forEach(clip => {
                objectsToAdd.push(clip);
            });
        });

        this.selection.selectMultipleObjects(objectsToAdd);
    }
    
    /**
     * Adds an image path to the active frame using a given asset as its image src.
     * @param {Wick.Asset} asset - the asset to use for the image src
     * @param {number} x - the x position to create the image path at
     * @param {number} y - the y position to create the image path at
     * @param {function} callback - the function to call after the path is created.
     */
    createImagePathFromAsset(asset, x, y, callback) {
		let playheadPosition = this.focus.timeline.playheadPosition;
		if (!this.activeFrame)
			this.activeLayer.insertBlankFrame(playheadPosition);
        asset.createInstance(path => {
            this.activeFrame.addPath(path);
            path.x = x;
            path.y = y;
            callback(path);
        });
    }

    /**
     * Adds an instance of a clip asset to the active frame.
     * @param {Wick.Asset} asset - the asset to create the clip instance from
     * @param {number} x - the x position to create the image path at
     * @param {number} y - the y position to create the image path at
     * @param {function} callback - the function to call after the path is created.
     */
    createClipInstanceFromAsset(asset, x, y, callback) {
		let playheadPosition = this.focus.timeline.playheadPosition;
		if (!this.activeFrame)
			this.activeLayer.insertBlankFrame(playheadPosition);
        asset.createInstance(clip => {
            this.activeFrame.addPath(clip);
            clip.x = x;
            clip.y = y;
            callback(clip);
        }, this);
    }

    /**
     * Adds an instance of a clip asset to the active frame.
     * @param {Wick.Asset} asset - the asset to create the SVG file instance from
     * @param {number} x - the x position to import the SVG file at
     * @param {number} y - the y position to import the SVG file at
     * @param {function} callback - the function to call after the path is created.
     */
    createSVGInstanceFromAsset(asset, x, y, callback) {
		let playheadPosition = this.focus.timeline.playheadPosition;
		if (!this.activeFrame)
			this.activeLayer.insertBlankFrame(playheadPosition);
        asset.createInstance(svg => {
            this.addObject(svg);
            svg.x = x;
            svg.y = y;
            //this.addObject(svg);
            callback(svg);
        });
    }

    /**
     * Creates a symbol from the objects currently selected.
     * @param {string} identifier - the identifier to give the new symbol
     * @param {string} type - "Clip" or "Button"
     */
    createClipFromSelection(args) {
        if (!args) {
            args = {};
        };

        if (args.type !== 'Clip' && args.type !== 'Button') {
            console.error('createClipFromSelection: invalid type: ' + args.type);
            return;
        }

        let clip;

        if (args.type === 'Button') {
            clip = new Wick[args.type]({
                project: this,
                identifier: args.identifier,
                transformation: new Wick.Transformation({
                    x: this.selection.x + this.selection.width / 2,
                    y: this.selection.y + this.selection.height / 2,
                }),
                objects: this.selection.getSelectedObjects('Canvas')
            });
        } else {
            clip = new Wick[args.type]({
                project: this.project,
                identifier: args.identifier,
                transformation: new Wick.Transformation({
                    x: this.selection.x + this.selection.width / 2,
                    y: this.selection.y + this.selection.height / 2,
                })
            });
            clip.addObjects(this.selection.getSelectedObjects('Canvas'));
        }

        // Add the clip to the frame prior to adding objects.
        this.activeFrame.addClip(clip);

        // TODO add to asset library
        this.selection.clear();
        this.selection.select(clip);
    }

    /**
     * Creates a symbol from the layers currently selected.
     */
    createClipFromLayers() {
        // Order the selected layers front-to-back
        let selectedLayers = this.selection.getSelectedObjects('Layer');
        selectedLayers.sort((layer1, layer2) => layer1.index - layer2.index);

        let newLayer = new Wick.Layer();
        let thisTimeline = selectedLayers[0].parentTimeline;
        thisTimeline.addLayer(newLayer);

        let maxLayerLength = selectedLayers.reduce((maxEnd, layer) => Math.max(maxEnd, layer.length), 0);
        let blankFrame = newLayer.insertBlankFrame(1);
        blankFrame.length = maxLayerLength;

        let clip = new Wick.Clip({
            project: this
        });
        let clipTimeline = clip.timeline;
        let firstClipLayer = clipTimeline.layers[0];
        selectedLayers.forEach(layer => {
            layer.remove();
            clipTimeline.addLayer(layer);
        });
        firstClipLayer.remove();

        blankFrame.addClip(clip);
        clip.isSynced = true;

        // TODO add to asset library
        this.selection.clear();
        this.selection.select(clip);
    }

    /**
     * Moves the selected paths and clips to their own layers.
     */
    distributeSelectionToLayers() {
        let thisSelected = this.selection.getSelectedObjects("Canvas");
        
        let thisTimeline = thisSelected[0].parentTimeline;
        let thisPlayheadPosition = thisTimeline.playheadPosition;

        thisSelected.forEach(object => {
            let newLayer = new Wick.Layer();
            thisTimeline.insertChild(newLayer, 0);
            newLayer.name = `Layer ${thisTimeline.layers.length}`;

            let newFrame = newLayer.insertBlankFrame(thisPlayheadPosition);
            object.remove();
            newFrame.addChild(object);
        });
    }

    /**
     * Breaks selected clips into their children clips and paths.
     */
    breakApartSelection() {
        var leftovers = [];
        var clips = this.selection.getSelectedObjects('Clip');

        this.selection.clear();

        clips.forEach(clip => {
            leftovers = leftovers.concat(clip.breakApart());
        });

        this.selection.selectMultipleObjects(leftovers);
    }

    /**
     * Sets the project focus to the timeline of the selected clip.
     * @returns {boolean} True if selected clip is focused, false otherwise.
     */
    focusTimelineOfSelectedClip() {
        if (this.selection.getSelectedObject() instanceof Wick.Clip) {
            this.focus = this.selection.getSelectedObject();
            return true;
        }
        return false;
    }

    /**
     * Sets the project focus to the parent timeline of the currently focused clip.
     * @returns {boolean} True if parent clip is focused, false otherwise.
     */
    focusTimelineOfParentClip() {
        if (!this.focus.isRoot) {
            this.focus = this.focus.parentClip;
            return true;
        }
        return false;
    }

    /**
     * Plays the sound in the asset library with the given name.
     * @param {string} assetName - Name of the sound asset to play
     * @param {Object} options - options for the sound. See Wick.SoundAsset.play
     */
    playSound(assetName, options) {
        var asset = this.getAssetByName(assetName);

        if(!asset) {
            console.warn('playSound(): No asset with name: "' + assetName + '"');
        } else if (!(asset instanceof Wick.SoundAsset)) {
            console.warn('playSound(): Asset is not a sound: "' + assetName + '"');
        } else {
            return this.playSoundFromAsset(asset, options);
        }

    }

    /**
     * Generates information for a single sound that is being played.
     * @param {Wick.Asset} asset - Asset to be played.
     * @param {Object} options - Options including start (ms), end (ms), offset (ms), src (sound source), filetype (string).
     */
    generateSoundInfo (asset, options) {
        if (!asset) return {};
        if (!options) options = {};

        let playheadPosition = this.focus.timeline.playheadPosition;

        let soundStartMS = (1000/this.framerate) * (playheadPosition - 1); // Adjust by one to account for sounds on frame 1 starting at 0ms.
        let soundEndMS = 0;
        let seekMS = options.seekMS || 0;

        if (options.frame) {
            let soundLengthInFrames = options.frame.end - (options.frame.start - 1);
            soundEndMS = soundStartMS +  (1000/this.framerate) * soundLengthInFrames;
        } else {
            soundEndMS = soundStartMS + asset.duration * 1000;
        }

        let soundInfo = {
            playheadPosition: playheadPosition,
            start: soundStartMS,
            end: soundEndMS,
            offset: seekMS,
            src: asset.src,
            filetype: asset.fileExtension,
            name: asset.name,
            volume: options.volume || 1,
            playedFrom: options.playedFrom || undefined, // uuid of object that played the sound.
        }

        return soundInfo
    }

    /**
     * Plays a sound from a presented asset.
     * @param {Wick.SoundAsset} asset - Name of the sound asset to play.
     */
    playSoundFromAsset (asset, options) {
        let soundInfo = this.generateSoundInfo(asset, options);
        this.soundsPlayed.push(soundInfo);
        return asset.play(options);
    }

    /**
     * Stops sound(s) currently playing.
     * @param {string} assetName - The name of the SoundAsset to stop.
     * @param {number} id - (optional) The ID of the sound to stop. Returned by playSound. If an ID is not given, all instances of the given sound asset will be stopped.
     */
    stopSound(id) {
        var asset = this.getAssetByName(assetName);
        if (!asset) {
            console.warn('stopSound(): No asset with name: "' + assetName + '"');
        } else if (!(asset instanceof Wick.SoundAsset)) {
            console.warn('stopSound(): Asset is not a sound: "' + assetName + '"');
        } else {
            return asset.stop(id);
        }
    }

    /**
     * Stops all sounds playing from frames and sounds played using playSound().
     */
    stopAllSounds() {
        // Stop all sounds started with Wick.Project.playSound();
        this.getAssets('Sound').forEach(soundAsset => {
            soundAsset.stop();
        });

        // Stop all sounds on frames
        this.getAllFrames().forEach(frame => {
            frame.stopSound();
        });
    }

    /**
     * Disable all sounds from playing
     */
    mute() {
        this._muted = true;
    }

    /**
     * Enable all sounds to play
     */
    unmute() {
        this._muted = false;
    }

    /**
     * Is the project currently muted?
     * @type {boolean}
     */
    get muted() {
        return this._muted;
    }

    /**
     * Should the project render black bars around the canvas area?
     * (These only show up if the size of the window/element that the project
     * is inside is a different size than the project dimensions).
     * @type {boolean}
     */
    get renderBlackBars () {
        return this._renderBlackBars;
    }

    set renderBlackBars (renderBlackBars) {
        this._renderBlackBars = renderBlackBars;
    }

    /**
     * In "Published Mode", all layers will be rendered even if they are set to be hidden.
     * This is enabled during GIF/Video export, and enabled when the project is run standalone.
     * @type {boolean}
     */
    get publishedMode() {
        return this._publishedMode;
    }

    set publishedMode (publishedMode) {
        let validModes = [false, "interactive", "imageSequence", "audioSequence"];

        if (validModes.indexOf(publishedMode) === -1) {
            throw new Error("Published Mode: " + publishedMode + " is invalid. Must be one of type: " + validModes);
        }

        this._publishedMode = publishedMode;
    }

    /**
     * Returns true if the project is published, false otherwise.
     */
    get isPublished () {
        return this.publishedMode !== false;
    }

    /**
     * Toggle whether or not to render borders around clips.
     * @type {boolean}
     */
    get showClipBorders() {
        return this._showClipBorders;
    }

    set showClipBorders(showClipBorders) {
        this._showClipBorders = showClipBorders;
    }

    /**
     * The current error, if one was thrown, during the last tick.
     * @type {Object}
     */
    get error() {
        return this._error;
    }

    set error (error) {
        if (this._error && error) {
            return;
        } else if (error && !this._error) {
            // console.error(error);
        }

        this._error = error;
    }

    /**
     * Schedules a script to be run at the end of the current tick.
     * @param {string} uuid - the UUID of the object running the script.
     * @param {string} name - the name of the script to run, see Tickable.possibleScripts.
     * @param {Object} parameters - An object of key,value pairs to send as parameters to the script which runs.
     */
    scheduleScript (uuid, name, parameters) {
        this._scriptSchedule.push({
            uuid: uuid,
            name: name,
            parameters: parameters,
        });
    }

    /**
     * Run scripts in schedule, in order based on Tickable.possibleScripts.
     */
    runScheduledScripts () {
        Wick.Tickable.possibleScripts.forEach(scriptOrderName => {
            this._scriptSchedule.forEach(scheduledScript => {
                var {uuid, name, parameters} = scheduledScript;

                // Make sure we only run the script based on the current iteration through possibleScripts
                if (name !== scriptOrderName) {
                    return;
                }

                // Run the script on the corresponding object!
                Wick.ObjectCache.getObjectByUUID(uuid).runScript(name, parameters);
            });
        });
    }

    /**
     * Checks if the project is currently playing.
     * @type {boolean}
     */
    get playing() {
        return this._playing;
    }

    /**
     * Start playing the project.
     * Arguments: onError: Called when a script error occurs during a tick.
     *            onBeforeTick: Called before every tick
     *            onAfterTick: Called after every tick
     * @param {object} args - Optional arguments
     */
    play(args) {
        if (!args) args = {};
        if (!args.onError) args.onError = () => {};
        if (!args.onBeforeTick) args.onBeforeTick = () => {};
        if (!args.onAfterTick) args.onAfterTick = () => {};

        window._scriptOnErrorCallback = args.onError;

        this._playing = true;
        this.view.paper.view.autoUpdate = false;

        if (this._tickIntervalID) {
            this.stop();
        }

        this.error = null;

        this.history.saveSnapshot('state-before-play');

        this.selection.clear();

        // Start tick loop
        this._tickIntervalID = setInterval(() => {
            args.onBeforeTick();

            this.tools.interact.determineMouseTargets();
            // console.time('tick');
            var error = this.tick();
            // console.timeEnd('tick');

            // console.time('update');
            this.view.paper.view.update();
            // console.timeEnd('update');

            if(error) {
                this.stop();
                return;
            }

            // console.time('afterTick');
            args.onAfterTick();
            // console.timeEnd('afterTick');
        }, 1000 / this.framerate);
    }

    /**
     * Ticks the project.
     * @returns {object} An object containing information about an error, if one occured while running scripts. Null otherwise.
     */
    tick () {
        this.root._identifier = 'Project';

        // Process input
        this._mousePosition = this.tools.interact.mousePosition;
        this._isMouseDown = this.tools.interact.mouseIsDown;

        this._keysDown = this.tools.interact.keysDown;
        this._currentKey = this.tools.interact.lastKeyDown;

        this._mouseTargets = this.tools.interact.mouseTargets;

        // Reset scripts before ticking
        this._scriptSchedule = [];

        // Tick the focused clip
        this.focus._attachChildClipReferences();

        this.focus.tick();

        this.runScheduledScripts();

        // Save the current keysDown
        this._lastMousePosition = {x: this._mousePosition.x, y: this._mousePosition.y};
        this._keysLastDown = [].concat(this._keysDown);

        this.view.render();

        if (this._error) {
            return this._error;
        } else {
            return null;
        }
    }

    /**
     * Stop playing the project.
     */
    stop() {
        this._playing = false;
        this.view.paper.view.autoUpdate = true;

        // Run unload scripts on all objects
        this.getAllFrames().forEach(frame => {
            frame.clips.forEach(clip => {
                clip.scheduleScript('unload');
            });
        });
        this.runScheduledScripts();

        this.stopAllSounds();

        clearInterval(this._tickIntervalID);
        this._tickIntervalID = null;

        // Loading the snapshot to restore project state also moves the playhead back to where it was originally.
        // We actually don't want this, preview play should actually move the playhead after it's stopped.
        var currentPlayhead = this.focus.timeline.playheadPosition;

        // Load the state of the project before it was played
        this.history.loadSnapshot('state-before-play');
        // Wick.ObjectCache.removeUnusedObjects(this);

        if (this.error) {
            // An error occured.
            var errorObjUUID = this._error.uuid;
            var errorObj = Wick.ObjectCache.getObjectByUUID(errorObjUUID);

            // Focus the parent of the object that caused the error so that we can select the error-causer.
            this.focus = errorObj.parentClip;

            // Select the object that caused the error
            this.selection.clear();
            this.selection.select(errorObj);

            window._scriptOnErrorCallback && window._scriptOnErrorCallback(this.error);
        } else {
            this.focus.timeline.playheadPosition = currentPlayhead;
        }

        this.resetCache();

        delete window._scriptOnErrorCallback;
    }

    /**
     * Inject the project into an element on a webpage and start playing the project.
     * @param {Element} element - the element to inject the project into
     */
    inject(element) {
        this.view.canvasContainer = element;
        this.view.fitMode = 'fill';
        this.view.canvasBGColor = this.backgroundColor.hex;

        window.onresize = function() {
            project.view.resize();
        }
        this.view.resize();
        this.view.prerender();

        this.focus = this.root;
        this.focus.timeline.playheadPosition = 1;

        this.publishedMode = "interactive";
        this.play({
            onAfterTick: (() => {
                this.view.render();
            }),
            onError: (error => {
                console.error('Project threw an error!');
                console.error(error);
            }),
        });
    }

    /**
     * Sets zoom and pan such that the canvas fits in the window, with some padding.
     */
    recenter () {
        this.pan = {x: 0, y: 0};

        var paddingResize = 0.96;
        this.zoom = this.view.calculateFitZoom();
        this.zoom *= paddingResize;
    }

    /**
     * Resets zoom and pan (zoom resets to 1.0, pan resets to (0,0)).
     */
    resetZoomAndPan () {
        this.pan = {x: 0, y: 0};
        this.zoom = 1;
    }

    /**
     * Zooms the canvas in.
     */
    zoomIn() {
        this.zoom *= 1.25;
    }

    /**
     * Zooms the canvas out.
     */
    zoomOut() {
        this.zoom *= 0.8;
    }

    /**
     * Resets all tools in the project.
     */
    resetTools () {
        for (let toolName of Object.keys(this.tools)) {
            let tool = this.tools[toolName];
            tool.reset();
        }
    }

    /**
     * All tools belonging to the project.
     * @type {Array<Wick.Tool>}
     */
    get tools() {
        return this._tools;
    }

    /**
     * The tool settings for the project's tools.
     * @type {Wick.ToolSettings}
     */
    get toolSettings() {
        return this._toolSettings;
    }

    /**
     * The currently activated tool.
     * @type {Wick.Tool}
     */
    get activeTool() {
        return this._activeTool;
    }

    set activeTool(activeTool) {
        var newTool;

        if (typeof activeTool === 'string') {
            var tool = this.tools[activeTool];
            if (!tool) {
                console.error('set activeTool: invalid tool: ' + activeTool);
            }
            newTool = tool;
        } else {
            newTool = activeTool;
        }

        // Clear selection if we changed between drawing tools
        if (newTool.name !== 'pan' &&
            newTool.name !== 'eyedropper' &&
            newTool.name !== 'cursor') {
            this.selection.clear();
        }

        this._activeTool = newTool;
    }

    /**
     * Returns an object associated with this project, by uuid.
     * @param {string} uuid 
     */
    getObjectByUUID(uuid) {
        return Wick.ObjectCache.getObjectByUUID(uuid);
    }

    /**
     * Adds an object to the project.
     * @param {Wick.Base} object
     * @return {boolean} returns true if the obejct was added successfully, false otherwise.
     */
    addObject(object) {
        if (object instanceof Wick.Path) {
            this.activeFrame.addPath(object);
        } else if (object instanceof Wick.Clip) {
            this.activeFrame.addClip(object);
        } else if (object instanceof Wick.Frame) {
            this.activeTimeline.addFrame(object);
        } else if (object instanceof Wick.Asset) {
            this.addAsset(object);
        } else if (object instanceof Wick.Layer) {
            this.activeTimeline.addLayer(object);
        } else if (object instanceof Wick.Tween) {
            this.activeTimeline.addTween(object);
        } else {
            return false;
        }
        return true;
    }


    /**
     * Create a sequence of images from every frame in the project.
     * @param {object} args - Options for generating the image sequence
     * @param {string} imageType - MIMEtype to use for rendered images. Defaults to 'image/png'.
     * @param {function} onProgress - Function to call for each image loaded, useful for progress bars
     * @param {function} onFinish - Function to call when the images are all loaded.
     */
    generateImageSequence (args) {
        if(!args) args = {};
        if(!args.imageType) args.imageType = 'image/png';
        if(!args.onProgress) args.onProgress = () => {};
        if(!args.onFinish) args.onFinish = () => {};
        if(!args.width) args.width = this.width;
        if(!args.height) args.height = this.height;

        

        var renderCopy = this;
        renderCopy.renderBlackBars = true; // Turn off black bars (removes black lines)

        var oldCanvasContainer = this.view.canvasContainer;

        this.history.saveSnapshot('before-gif-render');
        this.mute();
        this.selection.clear();
        this.publishedMode = "imageSequence";
        // this.tick();

        // Put the project canvas inside a div that's the same size as the project so the frames render at the correct resolution.
        let container = window.document.createElement('div');

        container.style.width  = (args.width/window.devicePixelRatio)+'px';
        container.style.height = (args.height/window.devicePixelRatio)+'px';
        window.document.body.appendChild(container);
        renderCopy.view.canvasContainer = container;
        renderCopy.view.resize();

        // Calculate the zoom needed to fit the project into the requested container width/height
        var zoom = 1;
        if (args.height < args.width) {
            zoom = args.height / this.height;
        } else {
            zoom = args.width / this.width;
        }

        // Set the initial state of the project.
        renderCopy.focus = renderCopy.root;
        renderCopy.focus.timeline.playheadPosition = 1;
        renderCopy.onionSkinEnabled = false;
        renderCopy.zoom = zoom / window.devicePixelRatio;
        renderCopy.pan = {x: 0, y: 0};

        // renderCopy.tick();

        // We need full control over when paper.js renders, if we leave autoUpdate on, it's possible to lose frames if paper.js doesnt automatically render as fast as we are generating the images.
        // (See paper.js docs for info about autoUpdate)
        renderCopy.view.paper.view.autoUpdate = false;

        var frameImages = [];
        var numMaxFrameImages = renderCopy.focus.timeline.length;
        var renderFrame = () => {
            var frameImage = new Image();

            frameImage.onload = () => {
                frameImages.push(frameImage);

                var currentPos = renderCopy.focus.timeline.playheadPosition;
                args.onProgress(currentPos, numMaxFrameImages);

                if (currentPos >= numMaxFrameImages) {
                    // reset autoUpdate back to normal
                    renderCopy.view.paper.view.autoUpdate = true;

                    this.view.canvasContainer = oldCanvasContainer;
                    this.view.resize();

                    this.history.loadSnapshot('before-gif-render');
                    this.publishedMode = false;
                    this.view.render();

                    window.document.body.removeChild(container);

                    args.onFinish(frameImages);
                } else {
                    var oldPlayhead = renderCopy.activeTimeline.playheadPosition
                    renderCopy.tick();
                    renderCopy.activeTimeline.playheadPosition = oldPlayhead + 1;
                    renderFrame();
                }
            }

            renderCopy.view.render();
            renderCopy.view.paper.view.update();
            frameImage.src = renderCopy.view.canvas.toDataURL(args.imageType);
        }

        this.resetSoundsPlayed();
        renderFrame();
    }

    resetSoundsPlayed () {
        this.soundsPlayed = [];
    }

    /**
     * Play the project through to generate an audio track.
     */
    generateAudioSequence (args) {
        if (!args) args = {};
        if (!args.onProgress) args.onProgress = (frame, maxFrames) => {}
        if (!args.onFinish) args.onFinish = (output) => {}

        var renderCopy = this;
        var oldCanvasContainer = this.view.canvasContainer;

        this.history.saveSnapshot('before-audio-render');
        this.mute();
        this.selection.clear();
        this.publishedMode = "audioSequence";

        // Put the project canvas inside a div that's the same size as the project so the frames render at the correct resolution.
        let container = window.document.createElement('div');
        container.style.width  = (args.width /window.devicePixelRatio)+'px';
        container.style.height = (args.height/window.devicePixelRatio)+'px';
        window.document.body.appendChild(container);
        renderCopy.view.canvasContainer = container;
        renderCopy.view.resize();

        // Set the initial state of the project.
        renderCopy.focus = renderCopy.root;
        renderCopy.focus.timeline.playheadPosition = 1;

        // renderCopy.tick(); // This is commented out to not miss frame 1.


        renderCopy.view.paper.view.autoUpdate = false;
        var numMaxFrameImages = renderCopy.focus.timeline.length;
        var renderFrame = () => {
            var currentPos = renderCopy.focus.timeline.playheadPosition;
            args.onProgress(currentPos, numMaxFrameImages);

            if(currentPos >= numMaxFrameImages) {
                // reset autoUpdate back to normal
                renderCopy.view.paper.view.autoUpdate = true;

                this.view.canvasContainer = oldCanvasContainer;
                this.view.resize();

                this.history.loadSnapshot('before-audio-render');
                this.publishedMode = false;
                this.view.render();

                window.document.body.removeChild(container);
                args.onFinish([...this.soundsPlayed]);
            } else {
                var oldPlayhead = renderCopy.activeTimeline.playheadPosition
                renderCopy.tick();
                renderCopy.activeTimeline.playheadPosition = oldPlayhead + 1;
                renderFrame();
            }
        }

        this.resetSoundsPlayed();
        renderFrame();
    }

    /**
     * Create an object containing info on all sounds in the project.
     * Format:
     *   start: The amount of time in milliseconds to cut from the beginning of the sound.
     *   end: The amount of time that the sound will play before stopping.
     *   offset: The amount of time to offset the start of the sound.
     *   src: The source of the sound as a dataURL.
     *   filetype: The file type of the sound asset.
     */
    getAudioInfo() {
        return this.root.timeline.frames.filter(frame => {
            return frame.sound !== null;
        }).map(frame => {
            return {
                start: frame.soundStartMS,
                end: frame.soundEndMS,
                offset: frame.soundStart,
                src: frame.sound.src,
                filetype: frame.sound.fileExtension,
            }
        });
    }

    /**
     * Generate an audiobuffer containing all the project's sounds merged together.
     * @param {object} args - takes soundInfo (list of soundInfo to use for audioGeneration).
     * @param {Function} callback - callback used to recieve the final audiobuffer.
     */
    generateAudioTrack(args, callback) {
        var audioTrack = new Wick.AudioTrack(this);

        audioTrack.toAudioBuffer({
            callback: callback,
            soundInfo: args.soundInfo ? args.soundInfo : undefined,
            onProgress: args.onProgress,
        });
    }

    /**
     * Check if an object is a mouse target (if the mouse is currently hovered over the object)
     * @param {Wick.Tickable} object - the object to check if it is a mouse target
     */
    objectIsMouseTarget(object) {
        return this._mouseTargets.indexOf(object) !== -1;
    }

    /**
     * Whether or not to hide the cursor while project is playing.
     * @type {boolean}
     */
    get hideCursor() {
        return this._hideCursor;
    }

    set hideCursor(hideCursor) {
        this._hideCursor = hideCursor;
    }

    /**
     * Returns true if there is currently an active frame to draw onto.
     * @type {boolean}
     */
    get canDraw() {
        return !this.activeLayer.locked &&
            !this.activeLayer.hidden;
    }

    /**
     * Loads all Assets in the project's asset library. This must be called after opening a project.
     * @param {function} callback - Called when all assets are done loading.
     */
    loadAssets(callback) {
        if (this.assets.length === 0) {
            callback();
            return;
        }

        var loadedAssetCount = 0;
        this.assets.forEach(asset => {
            asset.load(() => {
                loadedAssetCount++;
                if (loadedAssetCount === this.assets.length) {
                    callback();
                }
            });
        });
    }

    /**
     * Remove assets from the project that are never used.
     */
    cleanupUnusedAssets () {
        this.assets.forEach(asset => {
            if(!asset.hasInstances()) {
                asset.remove();
            }
        });
    }
}