diff --git a/README.md b/README.md index d39f2c87..a82e1289 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ +# Welcome to HashLips 👄 + +Important: There is a new repo for this code. +[https://github.com/HashLips/hashlips_art_engine](https://github.com/HashLips/hashlips_art_engine) + +All the code in these repos was created and explained by HashLips on the main YouTube channel. + +To find out more please visit: + +[đŸ“ē YouTube](https://www.youtube.com/channel/UC1LV4_VQGBJHTJjEWUmy8nA) + +[👄 Discord](https://discord.com/invite/qh6MWhMJDN) + +[đŸ’Ŧ Telegram](https://t.me/hashlipsnft) + +[đŸĻ Twitter](https://twitter.com/hashlipsnft) + +[â„šī¸ Website](https://hashlips.online/HashLips) + # generative-art-opensource Create generative art by using the canvas api and node js, feel free to contribute to this repo with new ideas. + +# Project Setup +- install `node.js` on your local system (https://nodejs.org/en/) +- clone the repository to your local system `git@github.com:HashLips/generative-art-opensource.git` +- run `yarn install` to install dependencies + +# How to use +## Run the code +1. Run `node index.js` +2. Open the `./output` folder to find your generated images to use as NFTs, as well as the metadata to use for NFT marketplaces. + +## Adjust the provided configuration and resources +### Configuration file +The file `./input/config.js` contains the following properties that can be adjusted to your preference in order to change the behavior of the NFT generation procedure: +- width: - of your image in pixels. Default: `1000px` +- height: - of your image in pixels. Default: `1000px` +- dir: - where image parts are stored. Default: `./input` +- description: - of your generated NFT. Default: `This is an NFT made by the coolest generative code.` +- baseImageUri: - URL base to access your NFTs from. This will be used by platforms to find your image resource. This expects the image to be accessible by it's id like `${baseImageUri}/${id}`. +- startEditionFrom: - number (int) to start naming NFTs from. Default: `1` +- editionSize: - number (int) to end edition at. Default: `10` +- editionDnaPrefix: - value (number or string) that indicates which dna from an edition is used there. I.e. dna `0` from to independent batches in the same edition may differ, and can be differentiated using this. Default: `0` +- rarityWeights: - allows to provide rarity categories and how many of each type to include in an edition. Default: `1 super_rare, 4 rare, 5 original` +- layers: list of layers that should be used to render the image. See next section for detail. + +### Image layers +The image layers are different parts that make up a full image by overlaying on top of each other. E.g. in the example input content of this repository we start with the eyeball and layer features like the eye lids or iris on top to create the completed and unique eye, which we can then use as part of our NFT collection. +To ensure uniqueness, we want to add various features and multiple options for each of them in order to allow enough permutations for the amount of unique images we require. + +To start, copy the layers/features and their images in a flat hierarchy at a directory of your choice (by default we expect them in `./input/`). The features should contain options for each rarity that is provided via the config file. + +After adding the `layers`, adjust them accordingly in the `config.js` by providing the directory path, positioning and sizes. +Use the existing `addLayers` calls as guidance for how to add layers. This can either only use the name of the layer and will use default positioning (x=0, y=0) and sizes (width=configured width, height=configure height), or positioning and sizes can be provided for more flexibility. + +### Allowing different rarities for certain rarity/layer combinations +It is possible to provide a percentage at which e.g. a rare item would contain a rare vs. common part in a given layer. This can be done via the `addRarityPercentForLayer` that can be found in the `config.js` as well. +This allows for more fine grained control over how much randomness there should be during the generation process, and allows a combination of common and rare parts. + +# Development suggestions +- Preferably use VSCode with the prettifier plugin for a consistent coding style (or equivalent js formatting rules) diff --git a/index.js b/index.js index 76b601b4..679e1270 100644 --- a/index.js +++ b/index.js @@ -8,16 +8,13 @@ const { baseImageUri, editionSize, startEditionFrom, - endEditionAt, rarityWeights, } = require("./input/config.js"); const console = require("console"); const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); -var metadataList = []; -var attributesList = []; -var dnaList = []; +// saves the generated image to the output folder, using the edition count as the name const saveImage = (_editionCount) => { fs.writeFileSync( `./output/${_editionCount}.png`, @@ -25,6 +22,7 @@ const saveImage = (_editionCount) => { ); }; +// adds a signature to the top left corner of the canvas const signImage = (_sig) => { ctx.fillStyle = "#000000"; ctx.font = "bold 30pt Courier"; @@ -33,6 +31,7 @@ const signImage = (_sig) => { ctx.fillText(_sig, 40, 40); }; +// generate a random color hue const genColor = () => { let hue = Math.floor(Math.random() * 360); let pastel = `hsl(${hue}, 100%, 85%)`; @@ -44,7 +43,8 @@ const drawBackground = () => { ctx.fillRect(0, 0, width, height); }; -const addMetadata = (_dna, _edition) => { +// add metadata for individual nft edition +const generateMetadata = (_dna, _edition, _attributesList) => { let dateTime = Date.now(); let tempMetadata = { dna: _dna.join(""), @@ -53,20 +53,23 @@ const addMetadata = (_dna, _edition) => { image: `${baseImageUri}/${_edition}`, edition: _edition, date: dateTime, - attributes: attributesList, + attributes: _attributesList, }; - metadataList.push(tempMetadata); - attributesList = []; + return tempMetadata; }; -const addAttributes = (_element) => { +// prepare attributes for the given element to be used as metadata +const getAttributeForElement = (_element) => { let selectedElement = _element.layer.selectedElement; - attributesList.push({ + let attribute = { name: selectedElement.name, rarity: selectedElement.rarity, - }); + }; + return attribute; }; +// loads an image from the layer path +// returns the image in a format usable by canvas const loadLayerImg = async (_layer) => { return new Promise(async (resolve) => { const image = await loadImage(`${_layer.selectedElement.path}`); @@ -82,92 +85,165 @@ const drawElement = (_element) => { _element.layer.size.width, _element.layer.size.height ); - addAttributes(_element); }; +// check the configured layer to find information required for rendering the layer +// this maps the layer information to the generated dna and prepares it for +// drawing on a canvas const constructLayerToDna = (_dna = [], _layers = [], _rarity) => { let mappedDnaToLayers = _layers.map((layer, index) => { - let selectedElement = layer.elements[_rarity][_dna[index]]; + let selectedElement = layer.elements.find(element => element.id === _dna[index]); return { location: layer.location, position: layer.position, size: layer.size, - selectedElement: selectedElement, + selectedElement: {...selectedElement, rarity: _rarity }, }; }); - return mappedDnaToLayers; }; -const getRarity = (_editionCount) => { - let rarity = ""; - rarityWeights.forEach((rarityWeight) => { - if ( - _editionCount >= rarityWeight.from && - _editionCount <= rarityWeight.to - ) { - rarity = rarityWeight.value; - } - }); - return rarity; -}; - +// check if the given dna is contained within the given dnaList +// return true if it is, indicating that this dna is already in use and should be recalculated const isDnaUnique = (_DnaList = [], _dna = []) => { let foundDna = _DnaList.find((i) => i.join("") === _dna.join("")); return foundDna == undefined ? true : false; }; +const getRandomRarity = (_rarityOptions) => { + let randomPercent = Math.random() * 100; + let percentCount = 0; + + for (let i = 0; i <= _rarityOptions.length; i++) { + percentCount += _rarityOptions[i].percent; + if (percentCount >= randomPercent) { + console.log(`use random rarity ${_rarityOptions[i].id}`) + return _rarityOptions[i].id; + } + } + return _rarityOptions[0].id; +} + +// create a dna based on the available layers for the given rarity +// use a random part for each layer const createDna = (_layers, _rarity) => { let randNum = []; + let _rarityWeight = rarityWeights.find(rw => rw.value === _rarity); _layers.forEach((layer) => { - let num = Math.floor(Math.random() * layer.elements[_rarity].length); - randNum.push(num); + let num = Math.floor(Math.random() * layer.elementIdsForRarity[_rarity].length); + if (_rarityWeight && _rarityWeight.layerPercent[layer.id]) { + // if there is a layerPercent defined, we want to identify which dna to actually use here (instead of only picking from the same rarity) + let _rarityForLayer = getRandomRarity(_rarityWeight.layerPercent[layer.id]); + num = Math.floor(Math.random() * layer.elementIdsForRarity[_rarityForLayer].length); + randNum.push(layer.elementIdsForRarity[_rarityForLayer][num]); + } else { + randNum.push(layer.elementIdsForRarity[_rarity][num]); + } }); return randNum; }; +// holds which rarity should be used for which image in edition +let rarityForEdition; +// get the rarity for the image by edition number that should be generated +const getRarity = (_editionCount) => { + if (!rarityForEdition) { + // prepare array to iterate over + rarityForEdition = []; + rarityWeights.forEach((rarityWeight) => { + for (let i = rarityWeight.from; i <= rarityWeight.to; i++) { + rarityForEdition.push(rarityWeight.value); + } + }); + } + return rarityForEdition[editionSize - _editionCount]; +}; + const writeMetaData = (_data) => { fs.writeFileSync("./output/_metadata.json", _data); }; +// holds which dna has already been used during generation +let dnaListByRarity = {}; +// holds metadata for all NFTs +let metadataList = []; +// Create generative art by using the canvas api const startCreating = async () => { + console.log('##################'); + console.log('# Generative Art'); + console.log('# - Create your NFT collection'); + console.log('##################'); + + console.log(); + console.log('start creating NFTs.') + + // clear meta data from previous run writeMetaData(""); + + // prepare dnaList object + rarityWeights.forEach((rarityWeight) => { + dnaListByRarity[rarityWeight.value] = []; + }); + + // create NFTs from startEditionFrom to editionSize let editionCount = startEditionFrom; - while (editionCount <= endEditionAt) { - console.log(editionCount); + while (editionCount <= editionSize) { + console.log('-----------------') + console.log('creating NFT %d of %d', editionCount, editionSize); + // get rarity from to config to create NFT as let rarity = getRarity(editionCount); - console.log(rarity); + console.log('- rarity: ' + rarity); + // calculate the NFT dna by getting a random part for each layer/feature + // based on the ones available for the given rarity to use during generation let newDna = createDna(layers, rarity); - console.log(dnaList); - - if (isDnaUnique(dnaList, newDna)) { - let results = constructLayerToDna(newDna, layers, rarity); - let loadedElements = []; //promise array - - results.forEach((layer) => { - loadedElements.push(loadLayerImg(layer)); - }); - - await Promise.all(loadedElements).then((elementArray) => { - ctx.clearRect(0, 0, width, height); - drawBackground(); - elementArray.forEach((element) => { - drawElement(element); - }); - signImage(`#${editionCount}`); - saveImage(editionCount); - addMetadata(newDna, editionCount); - console.log(`Created edition: ${editionCount} with DNA: ${newDna}`); - }); - dnaList.push(newDna); - editionCount++; - } else { - console.log("DNA exists!"); + while (!isDnaUnique(dnaListByRarity[rarity], newDna)) { + // recalculate dna as this has been used before. + console.log('found duplicate DNA ' + newDna.join('-') + ', recalculate...'); + newDna = createDna(layers, rarity); } + console.log('- dna: ' + newDna.join('-')); + + // propagate information about required layer contained within config into a mapping object + // = prepare for drawing + let results = constructLayerToDna(newDna, layers, rarity); + let loadedElements = []; + + // load all images to be used by canvas + results.forEach((layer) => { + loadedElements.push(loadLayerImg(layer)); + }); + + // elements are loaded asynchronously + // -> await for all to be available before drawing the image + await Promise.all(loadedElements).then((elementArray) => { + // create empty image + ctx.clearRect(0, 0, width, height); + // draw a random background color + drawBackground(); + // store information about each layer to add it as meta information + let attributesList = []; + // draw each layer + elementArray.forEach((element) => { + drawElement(element); + attributesList.push(getAttributeForElement(element)); + }); + // add an image signature as the edition count to the top left of the image + signImage(`#${editionCount}`); + // write the image to the output directory + saveImage(editionCount); + let nftMetadata = generateMetadata(newDna, editionCount, attributesList); + metadataList.push(nftMetadata) + console.log('- metadata: ' + JSON.stringify(nftMetadata)); + console.log('- edition ' + editionCount + ' created.'); + console.log(); + }); + dnaListByRarity[rarity].push(newDna); + editionCount++; } writeMetaData(JSON.stringify(metadataList)); }; -startCreating(); +// Initiate code +startCreating(); \ No newline at end of file diff --git a/input/config.js b/input/config.js index 3dfa5bce..aad063a7 100644 --- a/input/config.js +++ b/input/config.js @@ -1,104 +1,161 @@ +/************************************************************** + * UTILITY FUNCTIONS + * - scroll to BEGIN CONFIG to provide the config values + *************************************************************/ const fs = require("fs"); -const width = 1000; -const height = 1000; const dir = __dirname; -const description = "This is an NFT made by the coolest generative code."; -const baseImageUri = "https://hashlips/nft"; -const startEditionFrom = 1; -const endEditionAt = 10; -const editionSize = 10; -const rarityWeights = [ - { - value: "super_rare", - from: 1, - to: 1, - }, - { - value: "rare", - from: 2, - to: 5, - }, - { - value: "original", - from: 5, - to: editionSize, - }, -]; +// adds a rarity to the configuration. This is expected to correspond with a directory containing the rarity for each defined layer +// @param _id - id of the rarity +// @param _from - number in the edition to start this rarity from +// @param _to - number in the edition to generate this rarity to +// @return a rarity object used to dynamically generate the NFTs +const addRarity = (_id, _from, _to) => { + const _rarityWeight = { + value: _id, + from: _from, + to: _to, + layerPercent: {} + }; + return _rarityWeight; +}; + +// get the name without last 4 characters -> slice .png from the name const cleanName = (_str) => { let name = _str.slice(0, -4); return name; }; -const getElements = (path) => { +// reads the filenames of a given folder and returns it with its name and path +const getElements = (_path, _elementCount) => { return fs - .readdirSync(path) + .readdirSync(_path) .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) .map((i) => { return { + id: _elementCount, name: cleanName(i), - path: `${path}/${i}`, + path: `${_path}/${i}` }; }); }; +// adds a layer to the configuration. The layer will hold information on all the defined parts and +// where they should be rendered in the image +// @param _id - id of the layer +// @param _position - on which x/y value to render this part +// @param _size - of the image +// @return a layer object used to dynamically generate the NFTs +const addLayer = (_id, _position, _size) => { + if (!_id) { + console.log('error adding layer, parameters id required'); + return null; + } + if (!_position) { + _position = { x: 0, y: 0 }; + } + if (!_size) { + _size = { width: width, height: height } + } + // add two different dimension for elements: + // - all elements with their path information + // - only the ids mapped to their rarity + let elements = []; + let elementCount = 0; + let elementIdsForRarity = {}; + rarityWeights.forEach((rarityWeight) => { + let elementsForRarity = getElements(`${dir}/${_id}/${rarityWeight.value}`); + + elementIdsForRarity[rarityWeight.value] = []; + elementsForRarity.forEach((_elementForRarity) => { + _elementForRarity.id = `${editionDnaPrefix}${elementCount}`; + elements.push(_elementForRarity); + elementIdsForRarity[rarityWeight.value].push(_elementForRarity.id); + elementCount++; + }) + elements[rarityWeight.value] = elementsForRarity; + }); + + let elementsForLayer = { + id: _id, + position: _position, + size: _size, + elements, + elementIdsForRarity + }; + return elementsForLayer; +}; + +// adds layer-specific percentages to use one vs another rarity +// @param _rarityId - the id of the rarity to specifiy +// @param _layerId - the id of the layer to specifiy +// @param _percentages - an object defining the rarities and the percentage with which a given rarity for this layer should be used +const addRarityPercentForLayer = (_rarityId, _layerId, _percentages) => { + let _rarityFound = false; + rarityWeights.forEach((_rarityWeight) => { + if (_rarityWeight.value === _rarityId) { + let _percentArray = []; + for (let percentType in _percentages) { + _percentArray.push({ + id: percentType, + percent: _percentages[percentType] + }) + } + _rarityWeight.layerPercent[_layerId] = _percentArray; + _rarityFound = true; + } + }); + if (!_rarityFound) { + console.log(`rarity ${_rarityId} not found, failed to add percentage information`); + } +} + +/************************************************************** + * BEGIN CONFIG + *************************************************************/ + +// image width in pixels +const width = 1000; +// image height in pixels +const height = 1000; +// description for NFT in metadata file +const description = "This is an NFT made by the coolest generative code."; +// base url to use in metadata file +// the id of the nft will be added to this url, in the example e.g. https://hashlips/nft/1 for NFT with id 1 +const baseImageUri = "https://hashlips/nft"; +// id for edition to start from +const startEditionFrom = 1; +// amount of NFTs to generate in edition +const editionSize = 10; +// prefix to add to edition dna ids (to distinguish dna counts from different generation processes for the same collection) +const editionDnaPrefix = 0 + +// create required weights +// for each weight, call 'addRarity' with the id and from which to which element this rarity should be applied +let rarityWeights = [ + addRarity('super_rare', 1, 1), + addRarity('rare', 2, 5), + addRarity('original', 5, 10) +]; + +// create required layers +// for each layer, call 'addLayer' with the id and optionally the positioning and size +// the id would be the name of the folder in your input directory, e.g. 'ball' for ./input/ball const layers = [ - { - elements: { - original: getElements(`${dir}/ball/original`), - rare: getElements(`${dir}/ball/rare`), - super_rare: getElements(`${dir}/ball/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, - { - elements: { - original: getElements(`${dir}/eye color/original`), - rare: getElements(`${dir}/eye color/rare`), - super_rare: getElements(`${dir}/eye color/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, - { - elements: { - original: getElements(`${dir}/iris/original`), - rare: getElements(`${dir}/iris/rare`), - super_rare: getElements(`${dir}/iris/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, - { - elements: { - original: getElements(`${dir}/shine/original`), - rare: getElements(`${dir}/shine/rare`), - super_rare: getElements(`${dir}/shine/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, - { - elements: { - original: getElements(`${dir}/bottom lid/original`), - rare: getElements(`${dir}/bottom lid/rare`), - super_rare: getElements(`${dir}/bottom lid/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, - { - elements: { - original: getElements(`${dir}/top lid/original`), - rare: getElements(`${dir}/top lid/rare`), - super_rare: getElements(`${dir}/top lid/super_rare`), - }, - position: { x: 0, y: 0 }, - size: { width: width, height: height }, - }, + addLayer('ball', { x: 0, y: 0 }, { width: width, height: height }), + addLayer('eye color'), + addLayer('iris'), + addLayer('shine'), + addLayer('bottom lid'), + addLayer('top lid') ]; +// provide any specific percentages that are required for a given layer and rarity level +// all provided options are used based on their percentage values to decide which layer to select from +addRarityPercentForLayer('super_rare', 'ball', { 'super_rare': 33, 'rare': 33, 'original': 33 }); +addRarityPercentForLayer('super_rare', 'eye color', { 'super_rare': 50, 'rare': 25, 'original': 25 }); +addRarityPercentForLayer('original', 'eye color', { 'super_rare': 50, 'rare': 25, 'original': 25 }); + module.exports = { layers, width, @@ -107,6 +164,5 @@ module.exports = { baseImageUri, editionSize, startEditionFrom, - endEditionAt, rarityWeights, };