@bscotch/gml-parser
Version:
A parser for GML (GameMaker Language) files for programmatic manipulation and analysis of GameMaker projects.
802 lines • 31 kB
JavaScript
import { __decorate, __metadata } from "tslib";
import { pathy } from '@bscotch/pathy';
import { sequential } from '@bscotch/utility';
import { Yy, yyObjectEventSchema, yyRoomInstanceLayerSchema, yyRoomInstanceSchema, yySchemas, yySpriteSchema, } from '@bscotch/yy';
import { logger } from './logger.js';
import { Code } from './project.code.js';
import { Diagnostic } from './project.diagnostics.js';
import { Signifier } from './signifiers.js';
import { Type } from './types.js';
import { assert, getPngSize, groupPathToPosix, ok } from './util.js';
export function isAssetOfKind(asset, kind) {
return (asset !== null && typeof asset === 'object' && asset.assetKind === kind);
}
export function assertIsAssetOfKind(asset, kind) {
assert(isAssetOfKind(asset, kind), `Expected asset to be of kind ${kind}`);
}
export class Asset {
project;
resource;
$tag = 'Asset';
assetKind;
gmlFiles = new Map();
yy;
yyPath;
signifier;
/** For objects, their instance type. */
instanceType;
assetType;
variables;
nativeVariables;
/** For objects, their parent */
_parent = undefined;
initalized = {
globals: false,
locals: false,
};
constructor(project, resource, yyPath) {
this.project = project;
this.resource = resource;
assert(yyPath, 'Must provide a YY path');
this.assetKind = resource.id.path.split(/[/\\]/)[0];
this.yyPath = yyPath.withValidator(yySchemas[this.assetKind]);
// Create the symbol
this.signifier = new Signifier(this.project.self, this.name);
this.signifier.def = {};
this.signifier.global = true;
this.signifier.asset = true;
// Create the Asset.<> type
this.assetType = new Type(this.assetTypeKind).named(this.name);
this.signifier.setType(this.assetType);
this.assetType.signifier = this.signifier;
// Add this asset to the project lookup, unless it is a script.
if (!['scripts', 'extensions'].includes(this.assetKind)) {
this.project.self.addMember(this.signifier);
if (this.assetType.kind !== 'Any') {
this.project.types.set(this.typeName, this.assetType);
}
}
// If this is an object, also create the instance type
if (this.assetKind === 'objects') {
this.nativeVariables = Type.Struct;
// Create the base struct-type to store all of the variables.
for (const member of this.project.native.objectInstanceBase.listMembers()) {
if (member.name === 'id')
continue; // This is added later
const copy = member.copy();
copy.override = true; // Guarantee we keep this as a copy
this.nativeVariables.addMember(copy);
}
this.variables = new Type('Struct');
this.variables.extends = this.nativeVariables;
this.variables.signifier = this.signifier;
// It will be used as the parent for the Instance/Asset types
this.assetType.extends = this.variables;
this.instanceType = new Type('Id.Instance').named(this.name);
this.instanceType.extends = this.variables;
this.instanceType.signifier = this.signifier;
const id = new Signifier(this.variables, 'id', this.instanceType);
id.instance = true;
id.native = 'Base';
id.writable = false;
id.override = true; // We are guaranteeing that we're using
this.variables.addMember(id);
this.project.types.set(this.instanceTypeName, this.instanceType);
}
}
get typeName() {
return `${this.assetType.kind}.${this.name}`;
}
get instanceTypeName() {
return `Id.Instance.${this.name}`;
}
async saveYy() {
assert(this.yyPath, 'Cannot save YY without a path');
await Yy.write(this.yyPath.absolute, this.yy, this.assetKind, this.project.yyp);
}
get isScript() {
return this.assetKind === 'scripts';
}
get isObject() {
return this.assetKind === 'objects';
}
get isSound() {
return this.assetKind === 'sounds';
}
get isRoom() {
return this.assetKind === 'rooms';
}
get isSprite() {
return this.assetKind === 'sprites';
}
get isSpineSprite() {
if (this.assetKind !== 'sprites') {
return false;
}
const yy = this.yy;
return yy.type === 2;
}
get soundFile() {
assert(isAssetOfKind(this, 'sounds'), 'Can only get sound files from sound assets');
const yy = this.yy;
return this.dir.join(yy.soundFile);
}
get sprite() {
assert(isAssetOfKind(this, 'objects'), 'Can only get sprites from objects');
const yy = this.yy;
const spriteName = yy.spriteId?.name;
const sprite = spriteName
? this.project.getAssetByName(spriteName)
: undefined;
if (spriteName && !sprite) {
logger.warn(`Sprite ${spriteName} has no asset`);
}
return sprite;
}
set sprite(sprite) {
assert(isAssetOfKind(this, 'objects'), 'Can only set sprites on objects');
const yy = this.yy;
yy.spriteId = sprite ? sprite.resource.id : null;
// Fire off async to avoid blocking
void this.saveYy();
}
get children() {
assert(isAssetOfKind(this, 'objects'), 'Can only get children of objects');
const children = [];
for (const asset of this.project.assets.values()) {
if (isAssetOfKind(asset, 'objects') && asset.parent === this) {
children.push(asset);
}
}
return children;
}
get parent() {
return this._parent;
}
set parent(parent) {
const oldParent = this._parent;
if (oldParent === parent) {
// No change!
return;
}
this._parent = parent;
if (parent) {
// The instanceType parent is a struct that holds all of this
// object's instance variables. We need to set ITS parent to
// the parent's instanceType.
this.variables.extends = parent.variables;
}
else {
this.variables.extends = this.nativeVariables;
}
// Do we need to change the yy file?
const yy = this.yy;
const parentFromYy = this.project.getAssetByName(yy.parentObjectId?.name);
if (parentFromYy !== parent) {
// Then we need to update the yy. Just fire it off async for now so
// that this function can remain synchronous.
if (!parent) {
yy.parentObjectId = null;
}
else {
yy.parentObjectId = parent.resource.id;
}
// TODO: Maybe reprocess?
this.updateDiagnostics();
void this.saveYy();
}
}
/**
* Get the entire parent heirarchy, with immediate first
* and most-distant last
*/
get parents() {
if (!this.parent) {
return [];
}
return [this.parent, ...this.parent.parents];
}
/**
* Get the first GML file belonging to this resource.
* For scripts, this is the *only* GML file.*/
get gmlFile() {
return this.gmlFilesArray[0];
}
get gmlFilesArray() {
return [...this.gmlFiles.values()].sort((a, b) => {
if (a.name === 'Create_0') {
return -1;
}
else if (b.name === 'Create_0') {
return 1;
}
return 0;
});
}
getEventByName(name) {
assert(this.isObject, 'Can only get events for objects');
return this.gmlFilesArray.find((gml) => gml.name === name);
}
get shaderPaths() {
if (this.assetKind !== 'shaders') {
return undefined;
}
return {
vertex: this.yyPath.changeExtension('vsh'),
fragment: this.yyPath.changeExtension('fsh'),
};
}
get roomInstances() {
assertIsAssetOfKind(this, 'rooms');
const instances = new Map();
// Loop through each instance layer's instances to get IDs and objects
const yy = this.yy;
for (const layer of yy.layers) {
if (layer.resourceType !== 'GMRInstanceLayer') {
continue;
}
for (const instance of layer.instances || []) {
const obj = this.project.getAssetByName(instance.objectId.name);
if (isAssetOfKind(obj, 'objects')) {
instances.set(instance.name, obj);
}
}
}
// Loop through the instance order to get everything in the expected order
return yy.instanceCreationOrder
.map((x) => ({
instanceId: x.name,
object: instances.get(x.name),
}))
.filter((x) => !!x.object);
}
get frameIds() {
if (this.assetKind !== 'sprites') {
return [];
}
const yy = this.yy;
return yy.frames.map((f) => f.name);
}
get framePaths() {
const paths = [];
if (this.assetKind !== 'sprites') {
return paths;
}
const yy = this.yy;
for (const frame of yy.frames || []) {
paths.push(this.dir.join(`${frame.name}.png`));
}
return paths;
}
get spinePaths() {
if (!this.isSpineSprite) {
return undefined;
}
const yy = this.yy;
const frameId = yy.frames?.[0].name;
if (!frameId) {
return undefined;
}
return {
json: this.dir.join(`${frameId}.json`),
atlas: this.dir.join(`${frameId}.atlas`),
};
}
/**
* During an Object asset rename, we need to ensure that all references to the
* old name are updated to the new name. This includes the object's name in rooms.
*/
async renameRoomInstanceObjects(oldObjectName, newObjectName) {
assert(this.isRoom, 'Can only rename object instances in rooms'); // Iterate through each instance layer and remove any instances with the given ID
const yy = this.yy;
let didUpdate = false;
for (const layer of yy.layers) {
if (layer.resourceType !== 'GMRInstanceLayer') {
continue;
}
layer.instances.forEach((instance) => {
if (instance.objectId.name.toLowerCase() === oldObjectName.toLowerCase()) {
instance.objectId.name = newObjectName;
instance.objectId.path = `objects/${newObjectName}/${newObjectName}.yy`;
didUpdate = true;
}
});
}
if (didUpdate) {
await this.saveYy();
}
}
async removeRoomInstance(instanceId) {
assert(this.isRoom, 'Can only add object instances to rooms');
const yy = this.yy;
// Iterate through each instance layer and remove any instances with the given ID
for (const layer of yy.layers) {
if (layer.resourceType !== 'GMRInstanceLayer') {
continue;
}
layer.instances = (layer.instances || []).filter((x) => x.name !== instanceId);
}
// Remove the instance from the creation order
yy.instanceCreationOrder = (yy.instanceCreationOrder || []).filter((x) => x.name !== instanceId);
await this.saveYy();
}
async reorganizeRoomInstances(instanceIds) {
assert(this.isRoom, 'Can only add object instances to rooms');
const instanceIdsSet = new Set(instanceIds);
assert(instanceIds.length === instanceIdsSet.size, 'Cannot have duplicate instance IDs');
const yy = this.yy;
const currentIds = new Set(yy.instanceCreationOrder.map((x) => x.name));
// Ensure that the new order includes all existing instances
assert(instanceIdsSet.size === currentIds.size &&
[...instanceIdsSet].every((x) => currentIds.has(x)), 'New order must include all existing instances');
yy.instanceCreationOrder = instanceIds.map((name) => ({
name,
path: `rooms/${this.name}/${this.name}.yy`,
}));
await this.saveYy();
}
async addRoomInstance(obj, x = 0, y = 0) {
assert(this.isRoom, 'Can only add object instances to rooms');
const yy = this.yy;
// Ensure we have an instance layer
let instanceLayer = yy.layers.find((x) => x.resourceType === 'GMRInstanceLayer');
if (!instanceLayer) {
instanceLayer = yyRoomInstanceLayerSchema.parse({});
yy.layers.unshift(instanceLayer);
}
// Add a new instance
const instance = yyRoomInstanceSchema.parse({
objectId: obj.resource.id,
x,
y,
});
instanceLayer.instances.push(instance);
yy.instanceCreationOrder ||= [];
yy.instanceCreationOrder.push({
name: instance.name,
path: `rooms/${this.name}/${this.name}.yy`,
});
await this.saveYy();
}
get folder() {
return groupPathToPosix(this.yy.parent.path);
}
/**
* Check if this asset is in the given asset group ("folder").
* @param path E.g. `my/folder/of/stuff`
*/
isInFolder(path) {
// Normalize the incoming path
path = groupPathToPosix(path);
const currentFolder = this.folder;
return path === currentFolder || currentFolder.startsWith(`${path}/`);
}
/** Move to a different, *existing* folder. */
async moveToFolder(path) {
assert(path, 'Must provide a path with non-zero length!');
if (!path.endsWith('.yy')) {
path = `folders/${path}.yy`;
}
const folderName = path
.split(/[/\\]+/)
.pop()
.replace(/\.yy$/, '');
// Make sure that folder exists.
assert(folderName, 'Folder name is empty');
assert(this.project.yyp.Folders.find((x) => x.folderPath === path), `Folder ${path} does not exist`);
assert(this.yy.parent, 'Asset has no folder field');
// @ts-expect-error
logger.info('moving', this.name, 'from', this.yy.parent.path, 'to', path);
this.yy.parent = { name: folderName, path };
await this.saveYy();
}
/**
* Re-order the existing frames of a sprite.
* Any frames not included in the new order will be deleted.
*/
async reorganizeFrames(newFrameIdOrder) {
assert(this.isSprite, 'Can only delete frames from a sprite');
assert(!this.isSpineSprite, 'Cannot delete frames from a Spine sprite');
if (!newFrameIdOrder.length)
return;
let yy = this.yy;
const oldFrameIds = yy.frames.map((x) => x.name);
assert(newFrameIdOrder.every((x) => oldFrameIds.includes(x)), "Can't reorder frames that don't exist");
yy.frames = newFrameIdOrder.map((frameId) => {
const oldFrame = yy.frames.find((x) => x.name === frameId);
assert(oldFrame, 'Frame not found');
return oldFrame;
});
await this.saveYy();
// Delete any old frame images
await Promise.all(oldFrameIds
.filter((x) => !newFrameIdOrder.includes(x))
.map((frameId) => {
const path = this.dir.join(`${frameId}.png`);
return path.delete();
}));
}
async deleteFrames(frameIds) {
assert(this.isSprite, 'Can only delete frames from a sprite');
assert(!this.isSpineSprite, 'Cannot delete frames from a Spine sprite');
if (!frameIds.length)
return;
let yy = this.yy;
yy.frames = yy.frames.filter((x) => !frameIds.includes(x.name));
await this.saveYy();
// TODO: Delete the image files
await Promise.all(frameIds.map((frameId) => {
const path = this.dir.join(`${frameId}.png`);
return path.delete();
}));
}
async addFrames(sourceImages) {
assert(this.isSprite, 'Can only add frames to a sprite');
assert(!this.isSpineSprite, 'Cannot add frames to a Spine sprite');
if (!sourceImages.length)
return;
assert(sourceImages.every((x) => x.hasExtension('png')), 'All frames must be PNGs');
let yy = this.yy;
let expectedDims;
if (!yy.frames.length) {
// Then get the expected dimensions from the first image
expectedDims = await getPngSize(sourceImages[0]);
}
else {
expectedDims = {
width: yy.width,
height: yy.height,
};
}
const frameSizes = await Promise.all(sourceImages.map((x) => getPngSize(x)));
assert(frameSizes.every((x) => x.width === expectedDims.width && x.height === expectedDims.height), `Expected all frames to have width ${expectedDims.width} and height ${expectedDims.height}`);
const startingFrameCount = yy.frames.length;
// Update the YY file to get new frameIds
yy.frames.length = startingFrameCount + sourceImages.length;
yy = yySpriteSchema.parse(yy); // Will fill out the new frames
// Copy the new frames over.
await Promise.all(sourceImages.map((source, i) => {
const dest = this.dir.join(`${yy.frames[startingFrameCount + i].name}.png`);
return source.copy(dest);
}));
await this.saveYy();
}
async createEvent(eventInfo) {
assert(this.isObject, 'Can only create events for objects');
// Create the file if it doesn't already exist
const path = this.dir.join(`${eventInfo.name}.gml`);
if (!(await path.exists())) {
await path.write('/// ');
}
// Update the YY file
const yy = this.yy;
yy.eventList ||= [];
if (yy.eventList.find((x) => x.eventNum === eventInfo.eventNum &&
x.eventType === eventInfo.eventType)) {
logger.warn(`Event ${eventInfo.name} already exists on ${this.name}`);
return;
}
yy.eventList.push(yyObjectEventSchema.parse({
eventNum: eventInfo.eventNum,
eventType: eventInfo.eventType,
}));
await this.saveYy();
return this.addGmlFile(path);
}
async readYy() {
let asPath = pathy(this.yyPath);
if (!(await asPath.exists())) {
const filePattern = new RegExp(`${this.name}\\.yy$`, 'i');
const paths = await pathy(this.dir).listChildren();
asPath = paths.find((x) => filePattern.test(x.basename));
}
ok(asPath, `Could not find a .yy file for ${this.name}`);
this.yy = await Yy.read(asPath.absolute, this.assetKind);
return this.yy;
}
get dir() {
return this.yyPath.up();
}
get name() {
return this.resource.id.name;
}
/**
* Reprocess an existing file after it has been modified.
*/
async reloadFile(path, virtualContent) {
const gml = this.getGmlFile(path);
if (!gml) {
return;
}
await gml.reload(virtualContent, { reloadDirty: true });
}
getGmlFile(path) {
assert(path, 'GML Path does not exist');
return this.gmlFiles.get(path.absolute.toLocaleLowerCase());
}
updateParent() {
if (this.assetKind !== 'objects') {
return;
}
const yy = this.yy;
if (!yy.parentObjectId) {
return;
}
const parent = this.project.getAssetByName(yy.parentObjectId.name);
if (!parent || parent.assetKind !== 'objects') {
// TODO: Add diagnostic if parent missing
return;
}
// Set the parent
this.parent = parent;
}
updateGlobals(initial = false) {
this.updateParent();
// Ensure parent is updated first
if (initial && !this.initalized.globals && this.parent) {
this.parent.updateGlobals(initial);
}
else if (initial && this.initalized.globals) {
// Already initialized by a child
return;
}
for (const gml of this.gmlFilesArray) {
gml.updateGlobals();
}
this.initalized.globals = true;
}
updateAllSymbols(initial = false) {
// Ensure parent is updated first
if (initial && !this.initalized.locals && this.parent) {
this.parent.updateAllSymbols(initial);
}
else if (initial && this.initalized.locals) {
// Already initialized by a child
return;
}
for (const gml of this.gmlFilesArray) {
gml.updateAllSymbols();
}
this.initalized.locals = true;
}
updateDiagnostics() {
for (const gml of this.gmlFilesArray) {
gml.updateDiagnostics();
}
}
addGmlFile(path) {
const gml = this.getGmlFile(path) ||
new Code(this, path);
assert(path, 'Cannot add GML file, path does not exist');
this.gmlFiles.set(path.absolute.toLocaleLowerCase(), gml);
return gml;
}
async reload() {
// Find all immediate children, which might include legacy GML files
const [, children] = await Promise.all([
await this.readYy(),
this.dir.listChildren(),
]);
if (this.assetKind === 'scripts') {
this.addScriptFile(children);
}
else if (this.assetKind === 'objects') {
this.gmlFiles.clear();
this.addObjectFile(children);
}
else if (this.assetKind === 'extensions') {
const diagnostics = [];
// Load constants and functions from the extension
const typeIndexToName = (idx) => {
return idx === 1 ? 'String' : 'Real';
};
const yy = this.yy;
for (const file of yy.files) {
for (const constant of file.constants) {
if (constant.hidden) {
continue;
}
if (this.project.self.getMember(constant.name)) {
continue;
}
try {
// Get the type by parsing the value. For now we'll just check for number or string, else "Any".
const type = constant.value.startsWith('"')
? 'String'
: constant.value.match(/^-?[\d_.]+$/)
? 'Real'
: 'Any';
const signifier = new Signifier(this.project.self, constant.name, new Type(type));
signifier.macro = true;
signifier.global = true;
signifier.writable = false;
signifier.def = {};
this.project.self.addMember(signifier);
}
catch (err) {
diagnostics.push(new Diagnostic(`Error loading extension constant: ${constant.name}`, {
start: { line: 0, column: 0, offset: 0 },
end: { line: 0, column: 0, offset: 0 },
}, 'error', err));
}
}
for (const func of file.functions) {
if (func.hidden) {
continue;
}
if (this.project.self.getMember(func.externalName)) {
continue;
}
try {
const type = new Type('Function')
.named(func.externalName)
.describe(func.help);
type.setReturnType(new Type(typeIndexToName(func.returnType)));
for (let i = 0; i < func.args.length; i++) {
const typeIdx = func.args[i];
type.addParameter(i, `argument${i}`, {
type: new Type(typeIndexToName(typeIdx)),
});
}
const signifier = new Signifier(this.project.self, func.name, type);
signifier.global = true;
signifier.writable = false;
signifier.def = {};
this.project.self.addMember(signifier);
this.project.types.set(`Function.${func.externalName}`, type);
}
catch (err) {
diagnostics.push(new Diagnostic(`Error loading extension function: ${func.name}`, {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
}, 'error', err));
}
}
}
this.project.emitDiagnostics(this.yyPath.absolute, diagnostics);
}
await this.initiallyReadAndParseGml();
}
async onRemove() {
await Promise.all(this.gmlFilesArray.map((gml) => gml.remove()));
// Remove this signifier and any global types from the project
this.project.self.removeMember(this.signifier.name);
for (const typeName of [this.typeName, this.instanceTypeName]) {
const type = this.project.types.get(typeName);
if (type) {
this.project.types.delete(this.typeName);
// Try to get any refereneces using this type updated
for (const ref of type.signifier?.refs || []) {
ref.file.dirty = true;
}
}
}
// Remove the associated files
await this.dir.delete({ force: true, recursive: true });
}
addObjectFile(children) {
// Objects have one file per event, named after the event.
// The YY file includes the list of events, but references them by
// numeric identifiers instead of their name. For now we'll just
// assume that the GML files are correct.
children
.filter((p) => p.hasExtension('gml'))
.forEach((p) => this.addGmlFile(p));
}
addScriptFile(children) {
// Scripts should have exactly one GML file, which is the script itself,
// named the same as the script (though there could be casing variations)
const matches = children.filter((p) => p.basename.toLocaleLowerCase() ===
`${this.name?.toLocaleLowerCase?.()}.gml`);
if (matches.length !== 1) {
logger.error(`Script ${this.name} has ${matches.length} GML files. Expected 1.`);
}
else {
this.addGmlFile(matches[0]);
}
}
async initiallyReadAndParseGml() {
const parseWaits = [];
for (const file of this.gmlFilesArray) {
parseWaits.push(file.parse());
}
return await Promise.all(parseWaits);
}
get assetTypeKind() {
switch (this.assetKind) {
case 'objects':
return 'Asset.GMObject';
case 'rooms':
return 'Asset.GMRoom';
case 'scripts':
return 'Asset.GMScript';
case 'sprites':
return 'Asset.GMSprite';
case 'sounds':
return 'Asset.GMSound';
case 'paths':
return 'Asset.GMPath';
case 'shaders':
return 'Asset.GMShader';
case 'timelines':
return 'Asset.GMTimeline';
case 'fonts':
return 'Asset.GMFont';
default:
return 'Any';
}
}
static async from(project, resource) {
let yyPath = project.dir.join(resource.id.path);
if (!(await yyPath.exists())) {
const assetsDir = yyPath.up(2);
const namePattern = new RegExp(`^${resource.id.name}$`, 'i');
const dir = (await assetsDir.listChildren()).find((p) => p.basename.match(namePattern));
if (dir) {
const yyPattern = new RegExp(`^${resource.id.name}\\.yy$`, 'i');
yyPath = (await dir.listChildren()).find((p) => p.basename.match(yyPattern));
if (!yyPath) {
logger.warn(`Could not find file for "${resource.id.path}"`);
}
}
else {
logger.warn(`Could not find folder for "${resource.id.path}"`);
}
}
if (!yyPath) {
return;
}
const item = new Asset(project, resource, yyPath);
await item.reload();
return item;
}
}
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], Asset.prototype, "saveYy", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, String]),
__metadata("design:returntype", Promise)
], Asset.prototype, "renameRoomInstanceObjects", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], Asset.prototype, "removeRoomInstance", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array]),
__metadata("design:returntype", Promise)
], Asset.prototype, "reorganizeRoomInstances", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Asset, Object, Object]),
__metadata("design:returntype", Promise)
], Asset.prototype, "addRoomInstance", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array]),
__metadata("design:returntype", Promise)
], Asset.prototype, "reorganizeFrames", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array]),
__metadata("design:returntype", Promise)
], Asset.prototype, "deleteFrames", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array]),
__metadata("design:returntype", Promise)
], Asset.prototype, "addFrames", null);
//# sourceMappingURL=project.asset.js.map