diff --git a/README.md b/README.md index 98a393c2e..79afc3860 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,63 @@ const layerConfigurations = [ ]; ``` +In addtion, a couple of conditional rules of constructing layers to DNA are applied to adapt the situations as follows. An example of layerConfigurations in _src/config.js_ is listed below to explain these situations. + +```js +const layerConfigurations = [ + { + growEditionSizeTo: 5, + layersOrder: [ + { name: "Background" }, + { name: "Eyeball" }, + { name: "Eye color" }, + { name: "Iris" }, + { name: "Shine" }, + { name: "Bottom lid" }, + { name: "Top lid" }, + { name: "Fur", + options: { + rarities: [90, [10,12,6,7,21,18]], + }, + }, + { name: "Hair", + options: { + subGroup: true, // existance of sub folder corresponding to each kind of fur + linkLayer: 7, // layer 7 of Fur + rarities: [ + [90, []], + ], + noResetRarities: false, + }, + }, + { name: "Mouth", + options: { + rarities: [80, []], + } + }, + { name: "Hats", + options: { + noneToReveal: ['Hat_type1.png', 'Hat_type2.png'], // list of hats + // revealed only when Hair is None + linkLayer: 8, // layer 8 of Hair + rarities: [100, [], []], + }, + }, + ], + }, +]; + +``` +1. If a layer folder has its sub-folders, each sub-folder contains image files which only apply to a certain trait (image file) of another specified layer, e.g. "_Hair_" layer has 'White', 'Yellow,' 'Golden' sub-folders, the hair-style files inside the 'White' sub-folder can only apply to 'White' fur trait (White.png) of "_Fur_" layer. In this case, the config.js file has to specify an object _{options: {subGroup: true, linkeLayer: 7}}_ in the "_Hair_" layer, where _{subGroup: true}_ means that this layer has sub-folders, and _{linkeLayer: 7}_ means that the linked layer is the _7th_ layer (layer index starts from 0) of "_Fur_". ## Please note that the following conventions must be complied: 1) all the sub-folders must have their corresponding image files in the their linkLayer, and vice versa; 2) each sub-folder name must be exactly the same as its corresponding linkLayer image file name without its extension. e.g. if "_Fur_" layer has three image files: 'White.png', 'Yellow.png' and 'Golden.png', "_Hair_" layer must have and can only have three sub-folders of 'White', 'Yellow' and 'Golden'. +2. Some of image files for traits in a layer only apply/reveal when the trait of a specified layer is None (None.png blank image file), and the rest files apply when the trait of the specified layer is not None. This condition is referred to as '**NoneToReveal**' hereafter. As an example shown above, layer "_Hats_" specifies in its options object _{noneToReveal: ['Hat_type1.png', 'Hat_type2.png'], linkLayer: 8}_, which means if the trait of "_Hair_" of the linkLayer 8 (layer index starts from 0) is None, "_Hats_" layer can only choose from the list of _['Hat_type1.png', 'Hat_type2.png']_, else all the rest files under "_Hats_" folder except those in the list. + +A util of utils/set_rarities.js can be used to set rarity weights for each trait file automatically by specifying _{rarities: {number, []}_ in its options object of each layer: +1) If no rarities object specified, all the traits are set the same weight evenly, and None is not allowed; layers "_Background_", "_Eyeball_", "_Eye color_", "_Iris_", "_Shine_", "_Bottom lid_" and "_Top lid_" are the case; +2) Rarity weights are configured in an object _{rarities: {number, []}_, where _number_% is the sum of percentage weights of all traits except None trait, _(100-number)_% is the percentage weight of None trait; _[]_ after _number_ is a weight list corresponding to its trait files in alphabetical order. As an example shown in the above code, layer "_Fur_" rarities is to be set as _{options: {rarities: [90, [10,12,6,7,21,18]],}_, where 90 means the sum of all traits except None trait is 90%, and _[10,12,6,7,21,18]_ means there are 6 trait files excluding None, and these 6 trait files are assigned weights propotionally as specified in the list. Please note if the list is not empty, the number of the list elements must be the same as the number of trait files. If the list is empty, the available traits are set the same weight evenly, layer "_Mouth_" is the case. +3) For layers with sub-folders, such a format {rarities: _[[number, []], ...]}_ is applied, one _[number, []]_ is specified for each sub-folder. If only one _[number, []]_ is specified in the format _{rarities: [[numbler,[]],]}_, the specified _[number, []]_ will apply to all the sub-folders, layer _"Hair"_ is the case. +4) For the '**NoneToReveal**' condition as mentioned above, formart _{rarities: [number, [], []]}_ is applied, where _number_% is the sum of percentage weights of all traits except the noneToReveal list, the two sub lists are sequentially the weight lists for the traits excluding noneToReveal, and those in noneToReveal list. Here again an empty list means that weights are set evenly. +5) _{options: {noResetRarities: true}}_ may be specified for a layer in the case that rarity weights have already assigned for this layer, and no more update is needed. + Here is a list of the different blending modes that you can optionally use. ```js @@ -247,6 +304,14 @@ Create a preview image collage of your collection, run: npm run preview ``` +### Set specified rarities to image files + +Set rarities specified in src/config.js to image files , run: + +```sh +npm run set_rarities +``` + ### Generate pixelated images from collection In order to convert images into pixelated images you would need a list of images that you want to convert. So run the generator first. diff --git a/package.json b/package.json index 141abf44e..2502b1ca9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "pixelate": "node utils/pixelate.js", "update_info": "node utils/update_info.js", "preview_gif": "node utils/preview_gif.js", - "generate_metadata": "node utils/generate_metadata.js" + "generate_metadata": "node utils/generate_metadata.js", + "set_rarities": "node utils/set_rarities.js" }, "author": "Daniel Eugene Botha (HashLips)", "license": "MIT", diff --git a/src/config.js b/src/config.js index c46d867a0..42b16a49b 100644 --- a/src/config.js +++ b/src/config.js @@ -33,17 +33,48 @@ const layerConfigurations = [ { name: "Shine" }, { name: "Bottom lid" }, { name: "Top lid" }, + + // { name: "Fur", + // options: { + // rarities: [90, [10,12,6,7,21,18]], + // }, + // }, + // { name: "Hair", + // options: { + // subGroup: true, // existance of sub folder corresponding to each kind of fur + // linkLayer: 7, // layer 7 of Fur + // rarities: [ + // [90, []], + // ], + // noResetRarities: false, + // }, + // }, + // { name: "Mouth", + // options: { + // rarities: [80, []], + // } + // }, + // { name: "Hats", + // options: { + // noneToReveal: ['Hat_type1.png', 'Hat_type2.png'], // list of hats + // // revealed only when Hair is None + // linkLayer: 8, // layer 8 of Hair + // rarities: [100, [], []], + // }, + // }, ], }, ]; + +const src_none_file = `${basePath}/utils/None.png`; const shuffleLayerConfigurations = false; const debugLogs = false; const format = { - width: 512, - height: 512, + width: 2020, + height: 2020, smoothing: false, }; @@ -107,6 +138,7 @@ module.exports = { background, uniqueDnaTorrance, layerConfigurations, + src_none_file, rarityDelimiter, preview, shuffleLayerConfigurations, diff --git a/src/main.js b/src/main.js index e9c08dcf2..7b887c29b 100644 --- a/src/main.js +++ b/src/main.js @@ -35,7 +35,8 @@ let hashlipsGiffer = null; const buildSetup = () => { if (fs.existsSync(buildDir)) { - fs.rmdirSync(buildDir, { recursive: true }); + // fs.rmdirSync => fs.rmSync due to fs.rmdirSync being degraded + fs.rmSync(buildDir, { recursive: true }); } fs.mkdirSync(buildDir); fs.mkdirSync(`${buildDir}/json`); @@ -68,7 +69,11 @@ const cleanName = (_str) => { return nameWithoutWeight; }; -const getElements = (path) => { +/* get elements for all image files under a path. + The elements are put into a flat array. + return [{id, name, filename, path, weight}...] +*/ +const getFlatElements = (path) => { return fs .readdirSync(path) .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) @@ -86,26 +91,146 @@ const getElements = (path) => { }); }; +// get sub folders or file names under a path +const getSubDirs = (path) => { + return fs + .readdirSync(path) + .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) +} + +/* get elements for a layer with sub folders. + The elements are grouped into an array for the files under each sub folder, + and an object of {group: folder_name, elements: element_array} is created for + a sub folder, thus such multiple objects are arrayed together. + return [{ group: folder_name, + elements:[{id, name, filename, path, weight}...]}...] +*/ +const getRecursiveElements = (path) => { + return getSubDirs(path) + .map((obj)=>({ + group: obj, + elements: fs.readdirSync(path+obj) + .map((i, index)=> { + return { + id: index, + name:cleanName(i), + filename: i, + path: `${path}${obj}/${i}`, + weight: getRarityWeight(i), + } + }) + })) +} + +/* get elements for a layer where some images are revealed only when a + specified layer's image is None while others revealed else. + The elements are grouped in two objects, one has a key named 'noneToReveal' + for elements which only reveal when the specified layer's image is None, the + other has a key named 'overlapReveal' for elements which reveal when the + specified layer's image is not None. + return {noneToReveal: [{id, name, filename, path, weight}...], + overlapReveal: [{id, name, filename, path, weight}...]} +*/ +const getNoneToRevealElements = (layerObj) => { + // get all files under the layer + let allFiles = fs.readdirSync(`${layersDir}/${layerObj.name}/`) + .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) + + // get noneToRevealFiles/overlapRevealFiles by intersecting/differenting allFiles + // with/from noneToReveal files specified in the layerObj.options.noneToReveal + // noneToRevealFiles = allFiles.filter( (val) => { return layerObj.options.noneToReveal.indexOf(val) > -1}) + // overlapRevealFiles = allFiles.filter( (val) => { return layerObj.options.noneToReveal.indexOf(val) === -1}) + let noneToRevealFiles = [] + let overlapRevealFiles = [] + let pureNoneToReveail = layerObj.options.noneToReveal.map(item=>item.split('.').shift()) + + // console.log(pureNoneToReveail) + + allFiles.forEach (fl => { + if (pureNoneToReveail.includes(fl.split(rarityDelimiter).shift().split('.').shift())) { + noneToRevealFiles.push(fl) + } + else { + overlapRevealFiles.push(fl) + } + }) + + // get noneToRevealElements and overlapRevealElements + noneToRevealElements = + noneToRevealFiles.map((i, index) => ({ + id: index, + name: cleanName(i), + filename: i, + path: `${layersDir}/${layerObj.name}/${i}`, + weight: getRarityWeight(i), + })) + + overlapRevealElements = + overlapRevealFiles.map((i, index) => ({ + id: index, + name: cleanName(i), + filename: i, + path: `${layersDir}/${layerObj.name}/${i}`, + weight: getRarityWeight(i), + })) + + // form a object and return + return {noneToReveal: noneToRevealElements, overlapReveal:overlapRevealElements} +} + + +/* get elements (single_element = {id, name, filename, path, weight}) for a layerObj in + three different conditions: + 1. (layerObj.options?.['subGroup'] == true) => layer has sub folders + 2. (layerObj.options?.['noneToReveal']?.length > 0) => images are grouped together in + different conditons whether a specified other layer image is None or not + 3. normal contdition: elements are gathered in a flat array [single_element ...] +*/ +const getElements = (layerObj) => { + let elements = + layerObj.options?.['subGroup'] == true + ? getRecursiveElements(`${layersDir}/${layerObj.name}/`) + : (layerObj.options?.['noneToReveal']?.length > 0 + ? getNoneToRevealElements(layerObj) + : getFlatElements(`${layersDir}/${layerObj.name}/`) + ) + return elements +} + +// set up layers in a standard way to be used for createDna(), constructDnaLayer() etc. const layersSetup = (layersOrder) => { const layers = layersOrder.map((layerObj, index) => ({ - id: index, - elements: getElements(`${layersDir}/${layerObj.name}/`), - name: - layerObj.options?.["displayName"] != undefined - ? layerObj.options?.["displayName"] - : layerObj.name, - blend: - layerObj.options?.["blend"] != undefined - ? layerObj.options?.["blend"] - : "source-over", - opacity: - layerObj.options?.["opacity"] != undefined - ? layerObj.options?.["opacity"] - : 1, - bypassDNA: - layerObj.options?.["bypassDNA"] !== undefined - ? layerObj.options?.["bypassDNA"] + id: index, + elements: + getElements(layerObj), //elements may be grouped in three different ways + name: + layerObj.options?.["displayName"] != undefined + ? layerObj.options?.["displayName"] + : layerObj.name, + blend: + layerObj.options?.["blend"] != undefined + ? layerObj.options?.["blend"] + : "source-over", + opacity: + layerObj.options?.["opacity"] != undefined + ? layerObj.options?.["opacity"] + : 1, + bypassDNA: + layerObj.options?.["bypassDNA"] !== undefined + ? layerObj.options?.["bypassDNA"] + : false, + subGroup: + layerObj.options?.["subGroup"] !== undefined + ? layerObj.options?.["subGroup"] : false, + linkLayer: + layerObj.options?.["linkLayer"] !== undefined + ? layerObj.options?.["linkLayer"] + : -1, + noneToReveal: + layerObj.options?.["noneToReveal"] !== undefined + ? layerObj.options?.["noneToReveal"] + : [], })); return layers; }; @@ -134,12 +259,12 @@ const addMetadata = (_dna, _edition) => { name: `${namePrefix} #${_edition}`, description: description, image: `${baseUri}/${_edition}.png`, - dna: sha1(_dna), + // dna: sha1(_dna), edition: _edition, - date: dateTime, + // date: dateTime, ...extraMetadata, attributes: attributesList, - compiler: "HashLips Art Engine", + // compiler: "HashLips Art Engine", }; if (network == NETWORK.sol) { tempMetadata = { @@ -173,6 +298,9 @@ const addMetadata = (_dna, _edition) => { const addAttributes = (_element) => { let selectedElement = _element.layer.selectedElement; + if (selectedElement.name === 'None' | selectedElement.name === 'none' ) { + return + } attributesList.push({ trait_type: _element.layer.name, value: selectedElement.name, @@ -221,8 +349,28 @@ const drawElement = (_renderObject, _index, _layersLen) => { const constructLayerToDna = (_dna = "", _layers = []) => { let mappedDnaToLayers = _layers.map((layer, index) => { - let selectedElement = layer.elements.find( - (e) => e.id == cleanDna(_dna.split(DNA_DELIMITER)[index]) + /* As elements are grouped in three ways for each different kind of layer as specified in + layerConfigurations.layersOrder by the file src/config.js, elements are extracted for + each layer in their conresponding ways. + */ + // if the layer has sub_groups, the elements are nested in the sub_group + cur_elements = + layer.subGroup == true + ? (layer.elements.find(item => + item.group === cleanName(_dna.split(DNA_DELIMITER)[layer.linkLayer].split(':').pop()))).elements + : layer.elements + + // if some of images of the layer are only revealed when a specified layer image is None, + // the elements for these images are grouped in an object. + if (layer.noneToReveal.length > 0) { + cur_elements = + cleanName(_dna.split(DNA_DELIMITER)[layer.linkLayer].split(':').pop()) === 'None' + ? cur_elements['noneToReveal'] + : cur_elements['overlapReveal'] + } + + let selectedElement = cur_elements.find( + (e) => e.id == cleanDna(_dna.split(DNA_DELIMITER)[index]) ); return { name: layer.name, @@ -283,17 +431,36 @@ const createDna = (_layers) => { let randNum = []; _layers.forEach((layer) => { var totalWeight = 0; - layer.elements.forEach((element) => { + // if the layer has sub_groups, the elements are nested in the sub_group + randNum[layer.linkLayer] + cur_elements = + layer.subGroup == true + ? (layer.elements.find(item => + item.group === cleanName(randNum[layer.linkLayer].split(':').pop()))).elements + : layer.elements + // if some of images of the layer are only revealed when a specified layer image is None, + // the elements are grouped in an object. + if (layer.noneToReveal.length>0) { + if (cleanName(randNum[layer.linkLayer].split(':').pop())==='None') { + cur_elements = cur_elements['noneToReveal'] + } + else { + cur_elements = cur_elements['overlapReveal'] + } + } + + cur_elements.forEach((element) => { totalWeight += element.weight; }); + // number between 0 - totalWeight let random = Math.floor(Math.random() * totalWeight); - for (var i = 0; i < layer.elements.length; i++) { + for (var i = 0; i < cur_elements.length; i++) { // subtract the current weight from the random weight until we reach a sub zero value. - random -= layer.elements[i].weight; + random -= cur_elements[i].weight; if (random < 0) { return randNum.push( - `${layer.elements[i].id}:${layer.elements[i].filename}${ + `${cur_elements[i].id}:${cur_elements[i].filename}${ layer.bypassDNA ? "?bypassDNA=true" : "" }` ); diff --git a/utils/None.png b/utils/None.png new file mode 100644 index 000000000..0d7bdf98c Binary files /dev/null and b/utils/None.png differ diff --git a/utils/set_rarities.js b/utils/set_rarities.js new file mode 100644 index 000000000..204096ff6 --- /dev/null +++ b/utils/set_rarities.js @@ -0,0 +1,209 @@ +/* set_rarities.js + set rarities specified in src/config.js to image files +*/ + +const { publicDecrypt } = require("crypto"); +const fs = require("fs"); +const basePath = process.cwd(); + +const { + layerConfigurations, + src_none_file, + rarityDelimiter, +} = require(`${basePath}/src/config.js`); + +// add a new file with a rarity figure +const add_new_file_with_rarity = (file_name, rarity) => { + let head = file_name.slice(0,-4).split(rarityDelimiter).shift() + let tail = file_name.slice(-4) + fs.copyFileSync(src_none_file, head + rarityDelimiter + rarity + tail) +} + +// add rarity figure to an existing file +const add_rarity_to_exiting_file = (file_name, rarity) => { + let head = file_name.slice(0,-4).split(rarityDelimiter).shift() + let tail = file_name.slice(-4) + fs.renameSync(file_name, head + rarityDelimiter + rarity + tail) +} + +main = () => { + const layersDir = `${basePath}/layers/`; + let layer_Objs = layerConfigurations.map((layerconfig) => { + return layerconfig.layersOrder}) + + // normalise the rarity figures from config.js to layers_rarities + let layers_rarities = layer_Objs[0].map(obj => + obj.options + ? { name: obj.name, + subGroup: obj.options.subGroup? obj.options.subGroup : false, + noneToReveal: obj.options.noneToReveal? obj.options.noneToReveal : [], + rarities: obj.options.rarities? obj.options.rarities : [100, []], + noResetRarities: obj.options.noResetRarities? true : false} + : { name: obj.name, + subGroup: false, + noneToReveal: [], + rarities: [100, []], + noResetRarities: false} + ) + + // filter out the layers where its noResetRarities is true + layers_rarities = layers_rarities.filter(layer=>layer.noResetRarities===false) + + try { + layers_rarities.forEach(layer => { + console.log('Layer: %s', layer.name) + + // layer.rarities must be an array + if (!Array.isArray(layer.rarities)) { + throw new Error('rarities must be an array') + } + + // define a function to update rarities to corresponding trait files + const update_traits_with_rarities = (traits, rarities, dirPath) => { + let traits_rarities = [] // [[trait_file_name, rarity]] + if (rarities[1].length === 0) { + // when rarities for traits are not set as [], distribue them evenly + rarity_weight = (rarities[0]/traits.length).toFixed(2) + traits_rarities = traits.map(trait => [trait, rarity_weight]) + } + else { + if (rarities[1].length === traits.length) { + traits_rarities = traits.map((trt, i) => [trt, rarities[1][i]]) + } + else { + err_msg = 'Number of rarities (' + rarities[1].length + ')' + + ' do not match traits (' + traits.length + ')' + throw new Error(err_msg) + } + } + + // if total trait rarities rarities[0] <100, addin None.png with rarity + let none_trait = dirPath + '/None.png' + if (rarities[0] < 100){ + add_new_file_with_rarity(none_trait, 100 - rarities[0]) + } + // add rarities to trait files + traits_rarities.forEach(rarity => { + add_rarity_to_exiting_file(dirPath + rarity[0], rarity[1]) + }) + } + + // take a layer's rarities and options to validate and normalise the rarities + // and then call update_traits_with_rarities() to update rarities + const update_rarities = (rarities, dirPath, noneToReveal=[]) => { + // define a function for rarities format check, throw an error if incorrect + const rarities_format_check = (rarities, msg) => { + // ttotal trait-rarities number (rarities[0]) must be in (0,100] + if (typeof(rarities[0]) !== 'number' | rarities[0] > 100 | rarities[0] <= 0) { + throw new Error('%s: total trait-rarities number (rarities[0]) must be in (0,100]', msg) + } + + // trait-rarities rarities[1] must be an array, each rarity must be a non-negative number + if (!Array.isArray(rarities[1])) { + throw new Error('%s: trait-rarities rarities[1] must be an array', msg) + } + else { + if (rarities[1].length > 0 + & rarities[1].every(rarity => (typeof(rarity)!=='number') & rarity < 0)) { + throw new Error('%s: every trait rarity must be a non-negative number!', msg) + } + } + } + + // remove exiting None*.png files in dirPath in order to let only 'real' trait files exist + let existing_none_png_files = fs.readdirSync(dirPath) + .filter(item => /None/g.test(item)) + .filter(item => /\bpng/g.test(item)) + existing_none_png_files.forEach(none_file => { + fs.rmSync(dirPath + '/' + none_file) + }) + + if (noneToReveal.length === 0) { // No noneToReveal + rarities_format_check(rarities, layer.name) + let traits = fs.readdirSync(dirPath) + .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) + update_traits_with_rarities(traits, rarities, dirPath) + } + else { // at least one file is set in the noneToReveal + if (layer.rarities.length === 0) { // No rarities set for the layer + rarities = [[100, []], [40, []]] + } + else { // rarities set in the format [number, [...], [...]] + // [0]: [total rarities number for Non-nonToReveal + // [1]: rarities for noneToReveal, [2]: for the rest + if (layer.rarities.length === 3) { + rarities = [[100, layer.rarities[1]], [layer.rarities[0], layer.rarities[2]]] + } + else { + throw new Error('%s: rarities format must be either [] or [number, [], [])', layer.name) + } + } + + rarities_format_check(rarities[0], layer.name + '[noneToReveal]') + rarities_format_check(rarities[1], layer.name + '[Non-noneToReveal]') + + // put the trait files in traits = [[], []]: + // traits[0]: noneToReveal trait files, traits[1]: Non-noneToReveal trait files + let traits = [[], []] + let allFiles = fs.readdirSync(dirPath) + .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) + + let pureNoneToReveail = noneToReveal.map(item=>item.split('.').shift()) + allFiles.forEach (fl => { + if (pureNoneToReveail.includes(fl.split(rarityDelimiter).shift().split('.').shift())) { + traits[0].push(fl) + } + else { + traits[1].push(fl) + } + }) + + // update traits files with rarities + seq = [0, 1] + seq.forEach(i => {update_traits_with_rarities(traits[i], rarities[i], dirPath)}) + } + + } + + if (layer.subGroup) { // if subfolders exist, update_rarities for each folder + let subDirs = fs.readdirSync(layersDir + layer.name) + .filter((item) => !/(^|\/)\.[^\/\.]/g.test(item)) + if (layer.rarities.length === 0) { // if rarities == [] + subDirs.forEach(subDir => { + console.log(" - " + subDir) + update_rarities([0,[]], layersDir + layer.name + '/' + subDir + '/') + }) + } + else{ + if (layer.rarities.length === 1) { // if rarities == [[]], all subfolders are set with same rarities + subDirs.forEach(subDir => { + update_rarities(layer.rarities[0], layersDir + layer.name + '/' + subDir + '/') + }) + } + else { + if (subDirs.length !== layer.rarities.length) { + throw new Error("%s: Number of sub folders (%d) do not match rarities (%)", + layer.name, subDirs.length, layer.rarities.length) + } + for (let i=0; i 0) { // if at least one trait exists in noneToReveal + update_rarities(layer.rarities, layersDir + layer.name + '/', layer.noneToReveal) + } + else { + update_rarities(layer.rarities, layersDir + layer.name + '/') + } + } + }); + } catch (e) { + console.log('Script terminates: ', e.message) + } +} + +main()