Skip to content

Instantly share code, notes, and snippets.

@harel-tussi
Created March 18, 2022 21:49
Show Gist options
  • Save harel-tussi/c3907460375ca83d6cdc6e8807357511 to your computer and use it in GitHub Desktop.
Save harel-tussi/c3907460375ca83d6cdc6e8807357511 to your computer and use it in GitHub Desktop.
"use strict";
const path = require("path");
const isLocal = typeof process.pkg === "undefined";
const basePath = isLocal ? process.cwd() : path.dirname(process.execPath);
const fs = require("fs");
const keccak256 = require("keccak256");
const chalk = require("chalk");
const store = {};
const { createCanvas, loadImage } = require(path.join(
basePath,
"/node_modules/canvas"
));
const {
buildDir,
layersDir,
format,
baseUri,
description,
background,
uniqueDnaTorrance,
layerConfigurations,
rarityDelimiter,
shuffleLayerConfigurations,
debugLogs,
extraAttributes,
extraMetadata,
incompatible,
forcedCombinations,
traitValueOverrides,
outputJPEG,
emptyLayerName,
hashImages,
shuffledIndexes,
} = require(path.join(basePath, "/src/config.js"));
const canvas = createCanvas(format.width, format.height);
const ctx = canvas.getContext("2d");
var metadataList = [];
var attributesList = [];
var dnaList = [];
const buildSetup = () => {
if (fs.existsSync(buildDir)) {
fs.rmdirSync(buildDir, { recursive: true });
}
fs.mkdirSync(buildDir);
fs.mkdirSync(path.join(buildDir, "/json"));
fs.mkdirSync(path.join(buildDir, "/images"));
fs.mkdirSync(path.join(buildDir, "/hidden"));
};
const getRarityWeight = (_path) => {
// check if there is an extension, if not, consider it a directory
const exp = /#(\d*)/;
const weight = exp.exec(_path);
const weightNumber = weight ? Number(weight[1]) : null;
if (!weightNumber || isNaN(weightNumber)) {
return "required";
}
return weightNumber;
};
const cleanDna = (_str) => {
var dna = _str.split(":").shift();
return dna;
};
const cleanName = (_str) => {
const extension = /\.[0-9a-zA-Z]+$/;
const hasExtension = extension.test(_str);
let nameWithoutExtension = hasExtension ? _str.slice(0, -4) : _str;
var nameWithoutWeight = nameWithoutExtension.split(rarityDelimiter).shift();
return nameWithoutWeight;
};
/**
* Given some input, creates a sha256 hash.
* @param {Object} input
*/
const hash = (input) => {
const hashable = typeof input === Buffer ? input : JSON.stringify(input);
return keccak256(hashable).toString("hex");
};
/**
* Get't the layer options from the parent, or grandparent layer if
* defined, otherwise, sets default options.
*
* @param {Object} layer the parent layer object
* @param {String} sublayer Clean name of the current layer
* @returns {blendMode, opaticty} options object
*/
const getElementOptions = (layer, sublayer) => {
let blendMode = "source-over";
let opacity = 1;
if (layer.sublayerOptions?.[sublayer]) {
const options = layer.sublayerOptions[sublayer];
options.blend !== undefined ? (blendMode = options.blend) : null;
options.opacity !== undefined ? (opacity = options.blend) : null;
} else {
// inherit parent blend mode
blendMode = layer.blend != undefined ? layer.blend : "source-over";
opacity = layer.opacity != undefined ? layer.opacity : 1;
}
return { blendMode, opacity };
};
const getElements = (path, layer) => {
return fs
.readdirSync(path)
.filter((item) => {
const invalid = /(\.ini)/g;
return !/(^|\/)\.[^\/\.]/g.test(item) && !invalid.test(item);
})
.map((i, index) => {
const name = cleanName(i);
const extension = /\.[0-9a-zA-Z]+$/;
const sublayer = !extension.test(i);
const weight = getRarityWeight(i);
const { blendMode, opacity } = getElementOptions(layer, name);
const element = {
sublayer,
weight,
blendMode,
opacity,
id: index,
name,
filename: i,
path: `${path}${i}`,
};
if (sublayer) {
element.path = `${path}${i}`;
const subPath = `${path}${i}/`;
const sublayer = { ...layer, blend: blendMode, opacity };
element.elements = getElements(subPath, sublayer);
}
// Set trait type on layers for metadata
const lineage = path.split("/");
let typeAncestor;
if (weight !== "required") {
typeAncestor = element.sublayer ? 3 : 2;
}
if (weight === "required") {
typeAncestor = element.sublayer ? 1 : 3;
}
// we need to check if the parent is required, or if it's a prop-folder
if (lineage[lineage.length - typeAncestor].includes(rarityDelimiter)) {
typeAncestor += 1;
}
const parentName = lineage[lineage.length - typeAncestor];
element.trait = layer.sublayerOptions?.[parentName]
? layer.sublayerOptions[parentName].trait
: layer.trait !== undefined
? layer.trait
: parentName;
const rawTrait = getTraitValueFromPath(element, lineage);
const trait = processTraitOverrides(rawTrait);
element.traitValue = trait;
return element;
});
};
const getTraitValueFromPath = (element, lineage) => {
// If the element is a required png. then, the trait property = the parent path
// if the element is a non-required png. black%50.png, then element.name is the value and the parent Dir is the prop
if (element.weight !== "required") {
return element.name;
} else if (element.weight === "required") {
// if the element is a png that is required, get the traitValue from the parent Dir
return element.sublayer ? true : cleanName(lineage[lineage.length - 2]);
}
};
/**
* Checks the override object for trait overrides
* @param {String} trait The default trait value from the path-name
* @returns String trait of either overridden value of raw default.
*/
const processTraitOverrides = (trait) => {
return traitValueOverrides[trait] ? traitValueOverrides[trait] : trait;
};
const layersSetup = (layersOrder) => {
const layers = layersOrder.map((layerObj, index) => {
return {
id: index,
name: layerObj.name,
blendMode:
layerObj["blend"] != undefined ? layerObj["blend"] : "source-over",
opacity: layerObj["opacity"] != undefined ? layerObj["opacity"] : 1,
elements: getElements(`${layersDir}/${layerObj.name}/`, layerObj),
...(layerObj.display_type !== undefined && {
display_type: layerObj.display_type,
}),
bypassDNA:
layerObj.options?.["bypassDNA"] !== undefined
? layerObj.options?.["bypassDNA"]
: false,
};
});
return layers;
};
const saveImage = (_editionCount) => {
fs.writeFileSync(
`${buildDir}/images/${_editionCount}${outputJPEG ? ".jpg" : ".png"}`,
canvas.toBuffer(`${outputJPEG ? "image/jpeg" : "image/png"}`)
);
};
const genColor = () => {
let hue = Math.floor(Math.random() * 360);
let pastel = `hsl(${hue}, 100%, ${background.brightness})`;
return pastel;
};
const drawBackground = () => {
ctx.fillStyle = genColor();
ctx.fillRect(0, 0, format.width, format.height);
};
const addMetadata = (_dna, _edition, _prefixData) => {
let dateTime = Date.now();
const { _prefix, _offset, _imageHash } = _prefixData;
const combinedAttrs = [...attributesList, ...extraAttributes()];
const cleanedAttrs = combinedAttrs.reduce((acc, current) => {
const x = acc.find((item) => item.trait_type === current.trait_type);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, []);
let tempMetadata = {
dna: hash(_dna),
name: `${_prefix ? _prefix + " " : ""}#${_edition - _offset}`,
description: description,
image: `${baseUri}/${_edition}${outputJPEG ? ".jpg" : ".png"}`,
...(hashImages === true && { imageHash: _imageHash }),
edition: _edition,
date: dateTime,
...extraMetadata,
attributes: cleanedAttrs,
compiler: "Crypto Tribe Art Engine",
};
metadataList.push(tempMetadata);
attributesList = [];
};
const addAttributes = (_element) => {
let selectedElement = _element.layer;
const layerAttributes = {
trait_type: _element.layer.trait,
value: selectedElement.traitValue,
...(_element.layer.display_type !== undefined && {
display_type: _element.layer.display_type,
}),
};
if (
attributesList.some(
(attr) => attr.trait_type === layerAttributes.trait_type
)
)
return;
attributesList.push(layerAttributes);
};
const loadLayerImg = async (_layer) => {
return new Promise(async (resolve) => {
// selected elements is an array.
const image = await loadImage(`${_layer.path}`).catch((err) =>
console.log(chalk.redBright(`failed to load ${_layer.path}`, err))
);
resolve({ layer: _layer, loadedImage: image });
});
};
const drawElement = (_renderObject) => {
ctx.globalAlpha = _renderObject.layer.opacity;
ctx.globalCompositeOperation = _renderObject.layer.blendMode;
ctx.drawImage(_renderObject.loadedImage, 0, 0, format.width, format.height);
addAttributes(_renderObject);
};
const constructLayerToDna = (_dna = [], _layers = []) => {
let mappedDnaToLayers = _layers.map((layer, index) => {
let selectedElements = [];
const layerImages = _dna.filter(
(element) => element.split(".")[0] == layer.id
);
layerImages.forEach((img) => {
const indexAddress = cleanDna(img);
//
const indices = indexAddress.toString().split(".");
// const firstAddress = indices.shift();
const lastAddress = indices.pop(); // 1
// recursively go through each index to get the nested item
let parentElement = indices.reduce((r, nestedIndex) => {
if (!r[nestedIndex]) {
throw new Error("wtf");
}
return r[nestedIndex].elements;
}, _layers); //returns string, need to return
selectedElements.push(parentElement[lastAddress]);
});
// If there is more than one item whose root address indicies match the layer ID,
// continue to loop through them an return an array of selectedElements
return {
name: layer.name,
blendMode: layer.blendMode,
opacity: layer.opacity,
selectedElements: selectedElements,
...(layer.display_type !== undefined && {
display_type: layer.display_type,
}),
};
});
return mappedDnaToLayers;
};
/**
* In some cases a DNA string may contain optional query parameters for options
* such as bypassing the DNA isUnique check, this function filters out those
* items without modifying the stored DNA.
*
* @param {String} _dna New DNA string
* @returns new DNA string with any items that should be filtered, removed.
*/
const filterDNAOptions = (_dna) => {
const filteredDNA = _dna.filter((element) => {
const query = /(\?.*$)/;
const querystring = query.exec(element);
if (!querystring) {
return true;
}
const options = querystring[1].split("&").reduce((r, setting) => {
const keyPairs = setting.split("=");
return { ...r, [keyPairs[0]]: keyPairs[1] };
}, []);
return options.bypassDNA;
});
return filteredDNA;
};
/**
* Cleaning function for DNA strings. When DNA strings include an option, it
* is added to the filename with a ?setting=value query string. It needs to be
* removed to properly access the file name before Drawing.
*
* @param {String} _dna The entire newDNA string
* @returns Cleaned DNA string without querystring parameters.
*/
const removeQueryStrings = (_dna) => {
const query = /(\?.*$)/;
return _dna.replace(query, "");
};
const isDnaUnique = (_DnaList = [], _dna = []) => {
let foundDna = _DnaList.find((i) => i.join("") === _dna.join(""));
return foundDna == undefined ? true : false;
};
// expecting to return an array of strings for each _layer_ that is picked,
// should be a flattened list of all things that are picked randomly AND reqiured
/**
*
* @param {Object} layer The main layer, defined in config.layerConfigurations
* @param {Array} dnaSequence Strings of layer to object mappings to nesting structure
* @param {Number*} parentId nested parentID, used during recursive calls for sublayers
* @param {Array*} incompatibleDNA Used to store incompatible layer names while building DNA
* @param {Array*} forcedDNA Used to store forced layer selection combinations names while building DNA
* from the top down
* @returns Array DNA sequence
*/
function pickRandomElement(
layer,
dnaSequence,
parentId,
incompatibleDNA,
forcedDNA,
bypassDNA
) {
let totalWeight = 0;
// Does this layer include a forcedDNA item? ya? just return it.
const forcedPick = layer.elements.find((element) =>
forcedDNA.includes(element.name)
);
if (forcedPick) {
debugLogs
? console.log(chalk.yellowBright(`Force picking ${forcedPick.name}/n`))
: null;
let dnaString = `${parentId}.${forcedPick.id}:${forcedPick.filename}${bypassDNA}`;
return dnaSequence.push(dnaString);
}
if (incompatibleDNA.includes(layer.name) && layer.sublayer) {
debugLogs
? console.log(
`Skipping incompatible sublayer directory, ${layer.name}`,
layer.name
)
: null;
return dnaSequence;
}
const compatibleLayers = layer.elements.filter(
(layer) => !incompatibleDNA.includes(layer.name)
);
if (compatibleLayers.length === 0) {
debugLogs
? console.log(
"No compatible layers in the directory, skipping",
layer.name
)
: null;
return dnaSequence;
}
compatibleLayers.forEach((element) => {
// If there is no weight, it's required, always include it
// If directory has %, that is % chance to enter the dir
if (element.weight == "required" && !element.sublayer) {
let dnaString = `${parentId}.${element.id}:${element.filename}${bypassDNA}`;
dnaSequence.unshift(dnaString);
return;
}
if (element.weight == "required" && element.sublayer) {
const next = pickRandomElement(
element,
dnaSequence,
`${parentId}.${element.id}`,
incompatibleDNA,
forcedDNA,
bypassDNA
);
}
if (element.weight !== "required") {
totalWeight += element.weight;
}
});
// if the entire directory should be ignored…
// number between 0 - totalWeight
const currentLayers = compatibleLayers.filter((l) => l.weight !== "required");
let random = Math.floor(Math.random() * totalWeight);
for (var i = 0; i < currentLayers.length; i++) {
// subtract the current weight from the random weight until we reach a sub zero value.
// Check if the picked image is in the incompatible list
random -= currentLayers[i].weight;
// e.g., directory, or, all files within a directory
if (random < 0) {
// Check for incompatible layer configurations and only add incompatibilities IF
// chosing _this_ layer.
if (incompatible[currentLayers[i].name]) {
debugLogs
? console.log(
`Adding the following to incompatible list`,
...incompatible[currentLayers[i].name]
)
: null;
incompatibleDNA.push(...incompatible[currentLayers[i].name]);
}
// Similar to incompaticle, check for forced combos
if (forcedCombinations[currentLayers[i].name]) {
debugLogs
? console.log(
chalk.bgYellowBright.black(
`\nSetting up the folling forced combinations for ${currentLayers[i].name}: `,
...forcedCombinations[currentLayers[i].name]
)
)
: null;
forcedDNA.push(...forcedCombinations[currentLayers[i].name]);
}
// if there's a sublayer, we need to concat the sublayers parent ID to the DNA srting
// and recursively pick nested required and random elements
if (currentLayers[i].sublayer) {
return dnaSequence.concat(
pickRandomElement(
currentLayers[i],
dnaSequence,
`${parentId}.${currentLayers[i].id}`,
incompatibleDNA,
forcedDNA,
bypassDNA
)
);
}
// none/empty layer handler
if (currentLayers[i].name === emptyLayerName) {
return dnaSequence;
}
let dnaString = `${parentId}.${currentLayers[i].id}:${currentLayers[i].filename}${bypassDNA}`;
return dnaSequence.push(dnaString);
}
}
}
/**
* given the nesting structure is complicated and messy, the most reliable way to sort
* is based on the number of nested indecies.
* This sorts layers stacking the most deeply nested grandchildren above their
* immediate ancestors
* @param {[String]} layers array of dna string sequences
*/
const sortLayers = (layers) => {
return layers.sort((a, b) => {
const addressA = a.split(":")[0];
const addressB = b.split(":")[0];
return addressA.length - addressB.length;
});
};
const createDna = (_layers) => {
let dnaSequence = [];
let incompatibleDNA = [];
let forcedDNA = [];
_layers.forEach((layer) => {
const layerSequence = [];
pickRandomElement(
layer,
layerSequence,
layer.id,
incompatibleDNA,
forcedDNA,
layer.bypassDNA ? "?bypassDNA=true" : ""
);
const sortedLayers = sortLayers(layerSequence);
dnaSequence = [...dnaSequence, [sortedLayers]];
});
return dnaSequence.flat(2);
};
const writeMetaData = (_data) => {
fs.writeFileSync(`${buildDir}/json/_metadata.json`, _data);
};
const writeDnaLog = (_data) => {
fs.writeFileSync(`${buildDir}/_dna.json`, _data);
};
const saveMetaDataSingleFile = (_editionCount) => {
let metadata = metadataList.find((meta) => meta.edition == _editionCount);
debugLogs
? console.log(
`Writing metadata for ${_editionCount}: ${JSON.stringify(metadata)}`
)
: null;
fs.writeFileSync(
`${buildDir}/json/${_editionCount}.json`,
JSON.stringify(metadata, null, 2)
);
};
function shuffle(array) {
return shuffledIndexes;
let currentIndex = array.length,
randomIndex;
while (currentIndex != 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex],
];
}
return array;
}
const startCreating = async () => {
let layerConfigIndex = 0;
let editionCount = 1;
let failedCount = 0;
let abstractedIndexes = [];
for (
let i = 1;
i <= layerConfigurations[layerConfigurations.length - 1].growEditionSizeTo;
i++
) {
abstractedIndexes.push(i);
}
if (shuffleLayerConfigurations) {
abstractedIndexes = shuffle(abstractedIndexes);
}
debugLogs
? console.log("Editions left to create: ", abstractedIndexes)
: null;
while (layerConfigIndex < layerConfigurations.length) {
const layers = layersSetup(
layerConfigurations[layerConfigIndex].layersOrder
);
while (
editionCount <= layerConfigurations[layerConfigIndex].growEditionSizeTo
) {
let newDna = createDna(layers);
if (
isDnaUnique(dnaList, newDna) &&
// newDna.length > 1 &&
!store[filterDNAOptions(newDna)]
) {
let results = constructLayerToDna(newDna, layers);
debugLogs ? console.log("Created DNA:", newDna) : null;
let loadedElements = [];
// reduce the stacked and nested layer into a single array
const allImages = results.reduce((images, layer) => {
return [...images, ...layer.selectedElements];
}, []);
allImages.forEach((layer) => {
loadedElements.push(loadLayerImg(layer));
});
await Promise.all(loadedElements).then((renderObjectArray) => {
debugLogs ? console.log("Clearing canvas") : null;
ctx.clearRect(0, 0, format.width, format.height);
renderObjectArray.forEach((renderObject) => {
drawElement(renderObject);
});
// Draw the background last, always under
if (background.generate) {
ctx.globalCompositeOperation = "destination-over";
drawBackground();
}
debugLogs
? console.log("Editions left to create: ", abstractedIndexes)
: null;
saveImage(abstractedIndexes[0]);
// Metadata options
const savedFile = fs.readFileSync(
`${buildDir}/images/${abstractedIndexes[0]}${
outputJPEG ? ".jpg" : ".png"
}`
);
const _imageHash = hash(savedFile);
// if there's a prefix for the current configIndex, then
// start count back at 1 for the name, only.
const _prefix = layerConfigurations[layerConfigIndex].namePrefix
? layerConfigurations[layerConfigIndex].namePrefix
: null;
// if resetNameIndex is turned on, calculate the offset and send it
// with the prefix
let _offset = 0;
if (layerConfigurations[layerConfigIndex].resetNameIndex) {
_offset = layerConfigurations.reduce((acc, layer, index) => {
if (index < layerConfigIndex) {
acc += layer.growEditionSizeTo;
return acc;
}
return acc;
}, 0);
}
addMetadata(newDna, abstractedIndexes[0], {
_prefix,
_offset,
_imageHash,
});
saveMetaDataSingleFile(abstractedIndexes[0]);
// creating hidden metadata
console.log(
`Created edition: ${abstractedIndexes[0]}, with DNA: ${hash(
newDna
)}`
);
});
store[filterDNAOptions(newDna)] = true;
dnaList.push(filterDNAOptions(newDna));
editionCount++;
abstractedIndexes.shift();
} else {
console.log("DNA exists!");
failedCount++;
if (failedCount >= uniqueDnaTorrance) {
console.log(
`You need more layers or elements to grow your edition to ${layerConfigurations[layerConfigIndex].growEditionSizeTo} artworks!`
);
process.exit();
}
}
}
layerConfigIndex++;
}
writeMetaData(JSON.stringify(metadataList, null, 2));
writeDnaLog(JSON.stringify(dnaList, null, 2));
};
module.exports = { startCreating, buildSetup, getElements, layersSetup };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment