├── .prettierignore ├── assets └── preview.jpg ├── src ├── helpers │ ├── math.js │ ├── hash.js │ ├── screenshot.js │ ├── iota.js │ └── csv.js ├── pages │ ├── 404.js │ └── index.js ├── css │ └── style.css ├── components │ ├── copy │ │ ├── title.js │ │ └── instructions.js │ ├── modals │ │ ├── set-resolution.js │ │ ├── init-automation.js │ │ ├── set-hash.js │ │ └── run-automation.js │ ├── info │ │ └── features.js │ ├── panels │ │ ├── controls.js │ │ └── viewer.js │ └── inputs │ │ └── range-slider.js ├── hooks │ ├── useFeatures.js │ ├── useURL.js │ ├── useAutomation.js │ └── useHash.js └── containers │ └── app.js ├── Makefile ├── gatsby-config.js ├── .gitignore ├── .prettierrc ├── .eslintrc.js ├── package.json ├── LICENSE ├── lib └── connector.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public -------------------------------------------------------------------------------- /assets/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owmo-dev/token-art-tools/HEAD/assets/preview.jpg -------------------------------------------------------------------------------- /src/helpers/math.js: -------------------------------------------------------------------------------- 1 | export function clamp(num, min, max) { 2 | return Math.min(Math.max(num, min), max); 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound = () => { 4 | return

404

; 5 | }; 6 | 7 | export default NotFound; 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run-server: 2 | gatsby develop 3 | 4 | build: 5 | gatsby build 6 | 7 | serve: build 8 | gatsby serve 9 | 10 | deploy: 11 | npm run deploy 12 | -------------------------------------------------------------------------------- /src/helpers/hash.js: -------------------------------------------------------------------------------- 1 | function isValidHash(str) { 2 | const regexExp = /^0x[a-f0-9]{64}$/gi; 3 | return regexExp.test(str); 4 | } 5 | 6 | export {isValidHash}; 7 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pathPrefix: '/token-art-tools', 3 | siteMetadata: { 4 | title: 'Token Art Tool', 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from '../containers/app'; 3 | 4 | const Index = () => { 5 | return ; 6 | }; 7 | 8 | export default Index; 9 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | background:rgb(40,40,40); 5 | background: linear-gradient(0deg, rgba(40,40,40,1) 0%, rgba(60,60,60,1) 80%); 6 | } -------------------------------------------------------------------------------- /src/helpers/screenshot.js: -------------------------------------------------------------------------------- 1 | export function screenshot(hash) { 2 | var iframe = window.document.querySelector('iframe').contentWindow; 3 | if (iframe === undefined) return; 4 | iframe.postMessage({command: 'screenshot', token: hash}, '*'); 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .idea/ 3 | .vscode/ 4 | node_modules/ 5 | build 6 | .DS_Store 7 | *.tgz 8 | my-app* 9 | template/src/__tests__/__snapshots__/ 10 | lerna-debug.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | /.changelog 15 | .npm/ 16 | yarn.lock 17 | public/ 18 | *.env* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "printWidth": 160, 7 | "semi": true, 8 | "singleQuote": true, 9 | "tabWidth": 4, 10 | "trailingComma": "all", 11 | "useTabs": false 12 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: ['eslint:recommended', 'plugin:react/recommended'], 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | ecmaVersion: 12, 13 | sourceType: 'module', 14 | }, 15 | plugins: ['react'], 16 | rules: { 17 | 'react/prop-types': 0, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/helpers/iota.js: -------------------------------------------------------------------------------- 1 | export function iota(start = 0) { 2 | let count = start; 3 | let firstProp = true; 4 | return new Proxy( 5 | {}, 6 | { 7 | get(o, prop) { 8 | if (firstProp) { 9 | firstProp = false; 10 | return { 11 | // Enum descriptor 12 | get values() { 13 | return o; 14 | }, 15 | }; 16 | } 17 | if (prop in o) return o[prop]; 18 | else return (o[prop] = count++); 19 | }, 20 | }, 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/csv.js: -------------------------------------------------------------------------------- 1 | export function exportCSV(features) { 2 | let csvContent = 'data:text/csv;charset=utf-8,'; 3 | 4 | let keys = Object.keys(features[0]); 5 | csvContent += keys; 6 | csvContent += '\r\n'; 7 | 8 | features.map(feature => { 9 | csvContent += Object.keys(feature).map(key => { 10 | return feature[key]; 11 | }); 12 | csvContent += '\r\n'; 13 | return null; 14 | }); 15 | 16 | var encodedUri = encodeURI(csvContent); 17 | var hrefElement = document.createElement('a'); 18 | hrefElement.href = encodedUri; 19 | hrefElement.download = `features_${new Date().toJSON().slice(0, 10)}.csv`; 20 | document.body.appendChild(hrefElement); 21 | hrefElement.click(); 22 | hrefElement.remove(); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/copy/title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Header, Icon} from 'semantic-ui-react'; 3 | 4 | const Title = () => { 5 | return ( 6 |
7 | Token Art Tools 8 | 9 | created by 10 | 11 | Owen Moore 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default Title; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "token-art-tools", 3 | "version": "1.6.5", 4 | "private": true, 5 | "description": "Token Art Tools", 6 | "author": "Owen Moore", 7 | "keywords": [ 8 | "gatsby" 9 | ], 10 | "dependencies": { 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-range-step-input": "^1.3.0", 14 | "semantic-ui-react": "^2.1.4", 15 | "fomantic-ui-css": "^2.9.1" 16 | }, 17 | "scripts": { 18 | "deploy": "gatsby build --prefix-paths && gh-pages -d public -b deploy" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/owmo-dev/token-art-tools" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^8.32.0", 26 | "eslint-plugin-react": "^7.32.1", 27 | "gh-pages": "^4.0.0", 28 | "gatsby": "^5.4.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Owen Moore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/useFeatures.js: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useReducer, useMemo} from 'react'; 2 | import {iota} from '../helpers/iota'; 3 | 4 | const FeaturesContext = createContext(); 5 | 6 | export const {F_SET, F_CLEAR} = iota(); 7 | 8 | const init = { 9 | data: {}, 10 | }; 11 | 12 | function automationReducer(state, dispatch) { 13 | switch (dispatch.type) { 14 | case F_SET: { 15 | return {...state, data: dispatch.data}; 16 | } 17 | case F_CLEAR: { 18 | return init; 19 | } 20 | default: 21 | throw new Error(`automationReducer type '${dispatch.type}' not supported`); 22 | } 23 | } 24 | 25 | function FeaturesProvider(props) { 26 | const [state, dispatch] = useReducer(automationReducer, init); 27 | const value = useMemo(() => [state, dispatch], [state]); 28 | return ; 29 | } 30 | 31 | function useFeatures() { 32 | const context = useContext(FeaturesContext); 33 | if (!context) { 34 | throw new Error(`useFeatures must be used within the FeaturesProvider`); 35 | } 36 | return context; 37 | } 38 | 39 | export {useFeatures, FeaturesProvider}; 40 | -------------------------------------------------------------------------------- /src/containers/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Viewer from '../components/panels/viewer'; 4 | import Controls from '../components/panels/controls'; 5 | 6 | import 'fomantic-ui-css/semantic.css'; 7 | import '../css/style.css'; 8 | 9 | import {HashProvider} from '../hooks/useHash'; 10 | import {URLProvider} from '../hooks/useURL'; 11 | import {FeaturesProvider} from '../hooks/useFeatures'; 12 | import {AutomationProvider} from '../hooks/useAutomation'; 13 | 14 | const App = () => { 15 | return ( 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 |
33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /src/hooks/useURL.js: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useReducer, useMemo} from 'react'; 2 | import {iota} from '../helpers/iota'; 3 | 4 | const URLContext = createContext(); 5 | 6 | export const {U_SET, U_REFRESH, U_CLEAR} = iota(); 7 | 8 | const init = { 9 | url: '', 10 | isValid: false, 11 | iframeKey: '', 12 | }; 13 | 14 | function urlReducer(state, dispatch) { 15 | switch (dispatch.type) { 16 | case U_SET: { 17 | return { 18 | ...state, 19 | url: dispatch.url, 20 | isValid: validateURL(dispatch.url), 21 | iframeKey: getRandomString(), 22 | }; 23 | } 24 | case U_REFRESH: { 25 | return {...state, iframeKey: getRandomString()}; 26 | } 27 | case U_CLEAR: { 28 | return init; 29 | } 30 | default: 31 | throw new Error(`urlReducer type '${dispatch.type}' not supported`); 32 | } 33 | } 34 | 35 | function getRandomString() { 36 | return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); 37 | } 38 | 39 | function validateURL(string) { 40 | let url; 41 | 42 | try { 43 | url = new URL(string); 44 | } catch (_) { 45 | return false; 46 | } 47 | 48 | return url.protocol === 'http:' || url.protocol === 'https:'; 49 | } 50 | 51 | function URLProvider(props) { 52 | const [state, dispatch] = useReducer(urlReducer, init); 53 | const value = useMemo(() => [state, dispatch], [state]); 54 | return ; 55 | } 56 | 57 | function useURL() { 58 | const context = useContext(URLContext); 59 | if (!context) { 60 | throw new Error(`useURL must be used within the URLProvider`); 61 | } 62 | return context; 63 | } 64 | 65 | export {useURL, URLProvider}; 66 | -------------------------------------------------------------------------------- /lib/connector.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const params = new URLSearchParams(window.location.search); 3 | 4 | var hash = params.get('hash'); 5 | var number = params.get('number'); 6 | 7 | if (hash && number) tokenData = {hash: hash, tokenId: 1000000 + number}; 8 | 9 | alphabet = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; 10 | fxhash = 'oo' + hash.slice(2, 51); 11 | b58dec = str => [...str].reduce((p, c) => (p * alphabet.length + alphabet.indexOf(c)) | 0, 0); 12 | fxhashTrunc = fxhash.slice(2); 13 | regex = new RegExp('.{' + ((fxhashTrunc.length / 4) | 0) + '}', 'g'); 14 | hashes = fxhashTrunc.match(regex).map(h => b58dec(h)); 15 | sfc32 = (a, b, c, d) => { 16 | return () => { 17 | a |= 0; 18 | b |= 0; 19 | c |= 0; 20 | d |= 0; 21 | var t = (((a + b) | 0) + d) | 0; 22 | d = (d + 1) | 0; 23 | a = b ^ (b >>> 9); 24 | b = (c + (c << 3)) | 0; 25 | c = (c << 21) | (c >>> 11); 26 | c = (c + t) | 0; 27 | return (t >>> 0) / 4294967296; 28 | }; 29 | }; 30 | fxrand = sfc32(...hashes); 31 | 32 | var features = {}; 33 | 34 | function screenshot(name) { 35 | const art = document.querySelector('canvas'); 36 | const img = document.createElement('img'); 37 | const canvas = document.createElement('canvas'); 38 | canvas.width = art.width; 39 | canvas.height = art.height; 40 | canvas.getContext('2d').drawImage(art, 0, 0); 41 | 42 | let dataUrl = canvas.toDataURL('image/png'); 43 | img.src = dataUrl; 44 | 45 | var hrefElement = document.createElement('a'); 46 | hrefElement.href = dataUrl; 47 | document.body.append(hrefElement); 48 | hrefElement.download = name + '.png'; 49 | hrefElement.click(); 50 | hrefElement.remove(); 51 | } 52 | 53 | window.onload = function () { 54 | function handleMessage(e) { 55 | switch (e.data['command']) { 56 | case 'screenshot': 57 | screenshot(e.data['token']); 58 | break; 59 | case 'getFeatures': 60 | window.parent.postMessage({command: 'loadFeatures', features: features}, '*'); 61 | break; 62 | default: 63 | break; 64 | } 65 | } 66 | window.addEventListener('message', handleMessage); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/modals/set-resolution.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Modal, Form, Message, Button} from 'semantic-ui-react'; 3 | 4 | const SetResolution = props => { 5 | const [isError, setErrorState] = useState(false); 6 | const [isSubmitting, setSubmitState] = useState(false); 7 | 8 | const emptyFormData = {x: '', y: ''}; 9 | const [formData, setFormData] = useState(emptyFormData); 10 | 11 | function onChange(e) { 12 | setFormData(prev => ({ 13 | ...prev, 14 | [e.target.name]: e.target.value, 15 | })); 16 | } 17 | 18 | function cancel() { 19 | closeModal(true); 20 | } 21 | 22 | function closeModal() { 23 | setErrorState(false); 24 | setSubmitState(false); 25 | setFormData(emptyFormData); 26 | props.close(); 27 | } 28 | 29 | function handleSubmit() { 30 | setErrorState(false); 31 | setSubmitState(true); 32 | 33 | var x = parseInt(formData.x); 34 | var y = parseInt(formData.y); 35 | 36 | if (isNaN(x) || isNaN(y) || x < 10 || x > 10000 || y < 10 || y > 10000) { 37 | setErrorState(true); 38 | setSubmitState(false); 39 | return; 40 | } 41 | 42 | props.set(x, y); 43 | closeModal(); 44 | } 45 | 46 | return ( 47 | 48 | Set Custom Resolution 49 | 50 |
51 | 52 | 53 | 54 | 55 |
56 | {isError ? ERROR: numbers only, min 10, max 10,000 : null} 57 |
58 | 59 | 62 | 65 | 66 |
67 | ); 68 | }; 69 | 70 | export default SetResolution; 71 | -------------------------------------------------------------------------------- /src/hooks/useAutomation.js: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useReducer, useMemo} from 'react'; 2 | import {iota} from '../helpers/iota'; 3 | import {clamp} from '../helpers/math'; 4 | 5 | const AutomationContext = createContext(); 6 | 7 | export const {A_START, A_TICK, A_STOP, A_EXPORT, A_RESET} = iota(); 8 | 9 | const init = { 10 | status: 'idle', 11 | total: 0, 12 | doScreenshot: true, 13 | doCSVExport: false, 14 | waitTime: 2000, 15 | progress: 0, 16 | tick: 0, 17 | }; 18 | 19 | function automationReducer(state, dispatch) { 20 | switch (dispatch.type) { 21 | case A_START: { 22 | return { 23 | ...state, 24 | status: 'active', 25 | total: dispatch.total, 26 | doScreenshot: dispatch.doScreenshot, 27 | doCSVExport: dispatch.doCSVExport, 28 | waitTime: dispatch.waitTime, 29 | progress: 0, 30 | tick: 0, 31 | }; 32 | } 33 | case A_TICK: { 34 | let tick = state.tick + 1; 35 | let progress = clamp(parseInt((tick / state.total) * 100), 0, 100); 36 | return { 37 | ...state, 38 | status: 'active', 39 | progress: progress, 40 | tick: tick, 41 | }; 42 | } 43 | case A_STOP: { 44 | return { 45 | ...state, 46 | status: 'stopping', 47 | progress: 100, 48 | tick: state.total, 49 | }; 50 | } 51 | case A_EXPORT: { 52 | return { 53 | ...state, 54 | status: 'exporting', 55 | progress: 100, 56 | tick: state.total, 57 | }; 58 | } 59 | case A_RESET: { 60 | return init; 61 | } 62 | default: 63 | throw new Error(`automationReducer type '${dispatch.type}' not supported`); 64 | } 65 | } 66 | 67 | function AutomationProvider(props) { 68 | const [state, dispatch] = useReducer(automationReducer, init); 69 | const value = useMemo(() => [state, dispatch], [state]); 70 | return ; 71 | } 72 | 73 | function useAutomation() { 74 | const context = useContext(AutomationContext); 75 | if (!context) { 76 | throw new Error(`useAutomation must be used within the AutomationProvider`); 77 | } 78 | return context; 79 | } 80 | 81 | export {useAutomation, AutomationProvider}; 82 | -------------------------------------------------------------------------------- /src/components/modals/init-automation.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Modal, Form, Message, Button} from 'semantic-ui-react'; 3 | 4 | import {A_START, useAutomation} from '../../hooks/useAutomation'; 5 | 6 | const InitAutomation = props => { 7 | const {active, close} = props; 8 | 9 | const [, automationAction] = useAutomation(); 10 | 11 | const [isError, setErrorState] = useState(false); 12 | const [isSubmitting, setSubmitState] = useState(false); 13 | 14 | const emptyFormData = {total: 0, wait: 2000, csv: false}; 15 | const [formData, setFormData] = useState(emptyFormData); 16 | 17 | function onChange(e, v) { 18 | let data = v.type === 'checkbox' ? v.checked : v.value; 19 | setFormData(prev => ({ 20 | ...prev, 21 | [v.name]: data, 22 | })); 23 | } 24 | 25 | function closeModal() { 26 | setErrorState(false); 27 | setSubmitState(false); 28 | setFormData(emptyFormData); 29 | close(); 30 | } 31 | 32 | function handleSubmit() { 33 | setErrorState(false); 34 | setSubmitState(true); 35 | 36 | var t = parseInt(formData.total); 37 | var w = parseInt(formData.wait); 38 | var c = formData.csv; 39 | 40 | if (isNaN(t) || isNaN(w) || t < 2 || t > 10000 || w < 2000 || w > 10000) { 41 | setErrorState(true); 42 | setSubmitState(false); 43 | return; 44 | } 45 | 46 | closeModal(); 47 | 48 | automationAction({ 49 | type: A_START, 50 | total: t, 51 | doScreenshot: true, 52 | doCSVExport: c, 53 | waitTime: w, 54 | }); 55 | } 56 | 57 | return ( 58 | 59 | Setup Automation 60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 | {isError ? ERROR: Total and Wait numbers only, within ranges specified : null} 71 |
72 | 73 | 76 | 79 | 80 |
81 | ); 82 | }; 83 | 84 | export default InitAutomation; 85 | -------------------------------------------------------------------------------- /src/components/modals/set-hash.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Modal, Form, Message, Button} from 'semantic-ui-react'; 3 | import {H_SET, useHash} from '../../hooks/useHash'; 4 | import {isValidHash} from '../../helpers/hash'; 5 | 6 | const SetHash = props => { 7 | const [hash, hashAction] = useHash(); 8 | 9 | const [isError, setErrorState] = useState(false); 10 | const [error, setError] = useState(''); 11 | const [isSubmitting, setSubmitState] = useState(false); 12 | 13 | const {active, close} = props; 14 | 15 | const emptyFormData = {hash: '', number: ''}; 16 | const [formData, setFormData] = useState(emptyFormData); 17 | 18 | function onChange(e) { 19 | setFormData(prev => ({ 20 | ...prev, 21 | [e.target.name]: e.target.value, 22 | })); 23 | } 24 | 25 | function cancel() { 26 | closeModal(true); 27 | } 28 | 29 | function closeModal() { 30 | setErrorState(false); 31 | setSubmitState(false); 32 | setFormData(emptyFormData); 33 | close(); 34 | } 35 | 36 | function handleSubmit() { 37 | setErrorState(false); 38 | setSubmitState(true); 39 | 40 | if (formData.hash === '' && formData.number === '') { 41 | setError('ERROR: To submit you must provide either an Edition number or Hash string (or both at once)'); 42 | setErrorState(true); 43 | setSubmitState(false); 44 | return; 45 | } 46 | 47 | let h = formData.hash !== '' ? formData.hash : undefined; 48 | 49 | if (h) { 50 | if (!isValidHash(h)) { 51 | setError("ERROR: Hash string must be a valid 64 character hash, including '0x' at the start"); 52 | setErrorState(true); 53 | setSubmitState(false); 54 | return; 55 | } 56 | } 57 | 58 | let n = formData.number !== '' ? Number(formData.number) : undefined; 59 | 60 | if (n) { 61 | if (n < hash.params.start || n > hash.params.editions || !Number.isInteger(n)) { 62 | setError(`ERROR: Edition number must be an integer within ${hash.params.start} and ${hash.params.editions}`); 63 | setErrorState(true); 64 | setSubmitState(false); 65 | return; 66 | } 67 | } 68 | 69 | hashAction({type: H_SET, hash: h, number: n}); 70 | closeModal(); 71 | } 72 | 73 | return ( 74 | 75 | Set an Edition and/or Hash (overrides locks) 76 | 77 |
78 | 79 | 80 | 81 | 82 |
83 | {isError ? {error} : null} 84 |
85 | 86 | 89 | 92 | 93 |
94 | ); 95 | }; 96 | 97 | export default SetHash; 98 | -------------------------------------------------------------------------------- /src/components/info/features.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {List, Header, Loader} from 'semantic-ui-react'; 3 | 4 | import {useURL} from '../../hooks/useURL'; 5 | import {useHash} from '../../hooks/useHash'; 6 | import {F_CLEAR, F_SET, useFeatures} from '../../hooks/useFeatures'; 7 | 8 | const Features = () => { 9 | const [url] = useURL(); 10 | const [hash] = useHash(); 11 | const [features, featuresAction] = useFeatures(); 12 | 13 | const [isLoading, setLoading] = useState(false); 14 | const [list, setList] = useState([]); 15 | 16 | useEffect(() => { 17 | window.addEventListener('message', e => { 18 | switch (e.data['command']) { 19 | case 'loadFeatures': 20 | { 21 | featuresAction({type: F_SET, data: e.data['features']}); 22 | } 23 | break; 24 | default: 25 | break; 26 | } 27 | }); 28 | }, []); 29 | 30 | useEffect(() => { 31 | featuresAction({type: F_CLEAR}); 32 | if (!url.isValid) { 33 | setList([]); 34 | return; 35 | } 36 | 37 | setLoading(true); 38 | 39 | let timerGet = setTimeout(() => { 40 | var iframe = window.document.querySelector('iframe').contentWindow; 41 | if (iframe === undefined) return; 42 | iframe.postMessage({command: 'getFeatures'}, '*'); 43 | }, 600); 44 | 45 | let timerTimeout = setTimeout(() => { 46 | setLoading(false); 47 | }, 1200); 48 | 49 | return () => { 50 | clearTimeout(timerGet); 51 | clearTimeout(timerTimeout); 52 | }; 53 | }, [hash.hash, url.isValid, url.iframeKey, hash.number]); 54 | 55 | useEffect(() => { 56 | setList( 57 | Object.keys(features.data).map(key => { 58 | return ( 59 | 60 |
61 | {features.data[key].toString()} 62 | [ {key} ] 63 |
64 |
65 | ); 66 | }), 67 | ); 68 | }, [features.data]); 69 | 70 | const example = `features = { Feature: "Value of Feature"}`; 71 | 72 | return ( 73 |
82 |
83 | {!url.isValid ? ( 84 |
85 | {'assign variables (float, int, string) to global "features"'} 86 | {example} 87 |
88 | ) : url.isValid && list.length === 0 ? ( 89 | isLoading ? ( 90 | 91 | ) : ( 92 |
no features found
93 | ) 94 | ) : ( 95 | 96 | {list} 97 | 98 | )} 99 |
100 |
101 | ); 102 | }; 103 | 104 | export default Features; 105 | -------------------------------------------------------------------------------- /src/components/copy/instructions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Segment, Header, Icon, Input} from 'semantic-ui-react'; 3 | 4 | const Instructions = () => { 5 | const script = ''; 6 | const code = 'console.log(hash, number);'; 7 | const boilerplate = 'https://github.com/owmo-dev/token-art-tools-boilerplate'; 8 | const localURL = 'https://127.0.0.1:8080'; 9 | 10 | return ( 11 | 23 |
24 | 25 | include the connector.js script in your project 26 |
27 | 28 | { 34 | navigator.clipboard.writeText(script); 35 | }, 36 | }} 37 | value={script} 38 | readOnly={true} 39 | /> 40 | 41 |
42 | 43 | use the global variables (hash & number) in your script 44 |
45 | 46 | { 52 | navigator.clipboard.writeText(code); 53 | }, 54 | }} 55 | value={code} 56 | readOnly={true} 57 | /> 58 | 59 |
60 | 61 | host your script via http(s) server, custom boilerplate setup is available: 62 |
63 | 64 | { 70 | window.open(boilerplate); 71 | }, 72 | }} 73 | value={boilerplate} 74 | readOnly={true} 75 | /> 76 | 77 |
78 | 79 | enter the URL for your hosted work above 80 |
81 | 82 | { 88 | navigator.clipboard.writeText(localURL); 89 | }, 90 | }} 91 | value={localURL} 92 | readOnly={true} 93 | /> 94 | 95 |
96 | 97 | see the README for{' '} 98 | 99 | Token Art Tools on GitHub 100 | {' '} 101 | for more detailed usage instructions 102 |
103 |
104 | ); 105 | }; 106 | 107 | export default Instructions; 108 | -------------------------------------------------------------------------------- /src/hooks/useHash.js: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useReducer, useMemo} from 'react'; 2 | import {iota} from '../helpers/iota'; 3 | 4 | const HashContext = createContext(); 5 | 6 | export const {H_RANDOM, H_SET, H_BACK, H_CLEAR, H_SET_VALUE, H_LOCK, H_LOCK_NUM} = iota(); 7 | 8 | const actions = {}; 9 | 10 | function init() { 11 | let values = new Array(32).fill(0); 12 | let locked = new Array(32).fill(false); 13 | return { 14 | number: 0, 15 | hash: convertValuesToHash(values), 16 | values: values, 17 | locked: locked, 18 | numberLocked: false, 19 | history: [], 20 | params: { 21 | min: 0, 22 | max: 255, 23 | step: 1, 24 | count: 32, 25 | start: 0, 26 | editions: 1000, 27 | }, 28 | }; 29 | } 30 | 31 | function hashReducer(state, dispatch) { 32 | switch (dispatch.type) { 33 | case H_RANDOM: { 34 | let data = generateRandomValues(state); 35 | data['history'] = [...state.history, {hash: state.hash, number: state.number}]; 36 | return {...state, ...data}; 37 | } 38 | case H_SET: { 39 | let hash = dispatch?.hash ?? state.hash; 40 | let number = dispatch?.number ?? state.number; 41 | let values = convertHashToValues(hash); 42 | let history = [...state.history, {hash: state.hash, number: state.number}]; 43 | let data = {hash: hash, number: number, values: values, history: history}; 44 | return {...state, ...data}; 45 | } 46 | case H_BACK: { 47 | let last = state.history[state.history.length - 1]; 48 | let hash = last.hash; 49 | let number = last.number; 50 | let history = state.history; 51 | history.pop(); 52 | let values = convertHashToValues(hash); 53 | let data = {hash: hash, number: number, values: values, history: history}; 54 | return {...state, ...data}; 55 | } 56 | case H_CLEAR: { 57 | return {...state, ...init()}; 58 | } 59 | case H_SET_VALUE: { 60 | let values = state.values; 61 | values[dispatch.index] = dispatch.value; 62 | let data = { 63 | hash: convertValuesToHash(values), 64 | values: values, 65 | }; 66 | data['history'] = [...state.history, {hash: state.hash, number: state.number}]; 67 | return {...state, ...data}; 68 | } 69 | case H_LOCK: { 70 | let locked = state.locked; 71 | locked[dispatch.index] = !locked[dispatch.index]; 72 | return {...state, ...{locked: locked}}; 73 | } 74 | case H_LOCK_NUM: { 75 | let locked = !state.numberLocked; 76 | return {...state, numberLocked: locked}; 77 | } 78 | default: 79 | throw new Error(`hashReducer type '${dispatch.type}' not supported`); 80 | } 81 | } 82 | 83 | function generateRandomValues(state) { 84 | let values = state.values; 85 | for (let i = 0; i < 32; i++) { 86 | if (!state.locked[i]) values[i] = Math.floor(Math.random() * 255); 87 | } 88 | let number = !state.numberLocked ? Math.floor(state.params.start + Math.random() * state.params.editions) : state.number; 89 | return { 90 | hash: convertValuesToHash(values), 91 | values: values, 92 | number: number, 93 | }; 94 | } 95 | 96 | function convertHashToValues(hash) { 97 | let values = []; 98 | hash = hash.substring(2); 99 | for (let i = 0; i < hash.length; i += 2) { 100 | values.push(parseInt('0x' + hash[i] + hash[i + 1])); 101 | } 102 | return values; 103 | } 104 | 105 | function convertValuesToHash(values) { 106 | return '0x' + values.map(x => Number(x).toString(16).padStart(2, '0')).join(''); 107 | } 108 | 109 | function HashProvider(props) { 110 | const [state, dispatch] = useReducer(hashReducer, null, init); 111 | const value = useMemo(() => [state, dispatch], [state]); 112 | return ; 113 | } 114 | 115 | function useHash() { 116 | const context = useContext(HashContext); 117 | if (!context) { 118 | throw new Error(`useHash must be used within the HashProvider`); 119 | } 120 | return context; 121 | } 122 | 123 | export {useHash, HashProvider, actions}; 124 | -------------------------------------------------------------------------------- /src/components/modals/run-automation.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {Button, Modal, Progress, Icon} from 'semantic-ui-react'; 3 | 4 | import {H_RANDOM, useHash} from '../../hooks/useHash'; 5 | import {A_EXPORT, A_RESET, A_STOP, A_TICK, useAutomation} from '../../hooks/useAutomation'; 6 | import {useFeatures} from '../../hooks/useFeatures'; 7 | 8 | import {screenshot} from '../../helpers/screenshot'; 9 | import {exportCSV} from '../../helpers/csv'; 10 | 11 | const RunAutomation = () => { 12 | const [hash, hashAction] = useHash(); 13 | const [automation, automationAction] = useAutomation(); 14 | const [features] = useFeatures(); 15 | 16 | const [featuresList, setFeaturesList] = useState([]); 17 | 18 | const msg_cap = 'Generating & Capturing Images'; 19 | const msg_exp = 'Exporting CSV Features List'; 20 | 21 | const [isSubmitting, setSubmitState] = useState(false); 22 | const [message, setMessage] = useState(msg_cap); 23 | 24 | const [runner, setRunner] = useState(null); 25 | 26 | useEffect(() => { 27 | if (automation.status === 'active' && runner === null) { 28 | setFeaturesList([]); 29 | hashAction({type: H_RANDOM}); 30 | setRunner( 31 | setInterval(() => { 32 | automationAction({ 33 | type: A_TICK, 34 | }); 35 | }, automation.waitTime), 36 | ); 37 | } 38 | }, [automation.status]); 39 | 40 | useEffect(() => { 41 | if (automation.status === 'idle' && isSubmitting === true) { 42 | setSubmitState(false); 43 | } 44 | }, [automation.status]); 45 | 46 | useEffect(() => { 47 | if (automation.status === 'active') { 48 | if (automation.tick > 0 && automation.tick <= automation.total) { 49 | if (message !== msg_cap) setMessage(msg_cap); 50 | 51 | if (automation.doScreenshot) { 52 | screenshot(hash.hash); 53 | } 54 | 55 | if (automation.doCSVExport) { 56 | setTimeout(() => { 57 | let f = features.data; 58 | if (f !== undefined) { 59 | f['hash'] = hash.hash; 60 | f['edition'] = hash.number; 61 | setFeaturesList(prev => [...prev, f]); 62 | } 63 | }, 900); 64 | } 65 | } 66 | 67 | if (automation.tick === automation.total) { 68 | automationAction({type: A_STOP}); 69 | } else { 70 | hashAction({type: H_RANDOM}); 71 | } 72 | } 73 | }, [automation.status, automation.tick]); 74 | 75 | useEffect(() => { 76 | if (automation.status === 'stopping') { 77 | clearInterval(runner); 78 | setRunner(null); 79 | 80 | if (automation.doCSVExport) { 81 | if (message !== msg_exp) setMessage(msg_exp); 82 | setTimeout(() => { 83 | automationAction({type: A_EXPORT}); 84 | }, automation.waitTime); 85 | } else { 86 | setTimeout(() => { 87 | automationAction({type: A_RESET}); 88 | }, 500); 89 | } 90 | } 91 | }, [automation.status]); 92 | 93 | useEffect(() => { 94 | if (automation.status === 'exporting') { 95 | exportCSV(featuresList); 96 | setTimeout(() => { 97 | automationAction({type: A_RESET}); 98 | }, 500); 99 | } 100 | }); 101 | 102 | return ( 103 | 104 | Running Automation 105 | 106 | 107 | {message} 108 | 109 | 110 | 111 | 123 | 124 | 125 | ); 126 | }; 127 | 128 | export default RunAutomation; 129 | -------------------------------------------------------------------------------- /src/components/panels/controls.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useCallback} from 'react'; 2 | import {Segment, Grid, Button, Icon, Divider} from 'semantic-ui-react'; 3 | 4 | import {useURL} from '../../hooks/useURL'; 5 | import {H_BACK, H_CLEAR, H_RANDOM, useHash} from '../../hooks/useHash'; 6 | import {useAutomation} from '../../hooks/useAutomation'; 7 | 8 | import Title from '../copy/title'; 9 | import RangeSlider from '../inputs/range-slider'; 10 | import SetHash from '../modals/set-hash'; 11 | import InitAutomation from '../modals/init-automation'; 12 | import RunAutomation from '../modals/run-automation'; 13 | 14 | import {TYPE_HASH, TYPE_NUMBER} from '../inputs/range-slider'; 15 | 16 | const Controls = () => { 17 | const [url] = useURL(); 18 | const [hash, hashAction] = useHash(); 19 | const [automation] = useAutomation(); 20 | 21 | function createHashSliders({count, min, max, step}) { 22 | let sliders = []; 23 | for (let i = 0; i < count; i++) { 24 | sliders.push(); 25 | } 26 | return sliders; 27 | } 28 | 29 | const hashSliders = useCallback(createHashSliders({...hash.params}), [hash.params]); 30 | 31 | function createNumberSlider({start, editions}) { 32 | return ; 33 | } 34 | 35 | const numberSlider = useCallback(createNumberSlider({...hash.params}), [hash.params]); 36 | 37 | const [isSetHashModalOpen, setSetHashModalState] = useState(false); 38 | 39 | function openSetHashModal() { 40 | setSetHashModalState(true); 41 | } 42 | 43 | function closeSetHashModal() { 44 | setSetHashModalState(false); 45 | } 46 | 47 | const [isInitAutoModalOpen, setInitAutoModalState] = useState(false); 48 | 49 | function openInitAutoModal() { 50 | setInitAutoModalState(true); 51 | } 52 | 53 | function closeInitAutoModal() { 54 | setInitAutoModalState(false); 55 | } 56 | 57 | return ( 58 | <> 59 | 60 | 61 | 62 | 63 | 71 | 72 | </Grid.Column> 73 | <Grid.Column style={{width: 265, padding: 0, paddingTop: 25}}> 74 | <Button 75 | icon 76 | color="red" 77 | disabled={hash.history.length === 0 || automation.status !== 'idle'} 78 | style={{float: 'right', marginLeft: 12}} 79 | onClick={() => { 80 | hashAction({type: H_CLEAR}); 81 | }} 82 | > 83 | <Icon name="x" /> 84 | </Button> 85 | <Button 86 | icon 87 | color="purple" 88 | disabled={hash.history.length === 0 || automation.status !== 'idle'} 89 | style={{float: 'right', marginLeft: 12}} 90 | onClick={() => { 91 | hashAction({type: H_BACK}); 92 | }} 93 | > 94 | <Icon name="undo" /> 95 | </Button> 96 | <Button 97 | icon 98 | color="pink" 99 | disabled={!url.isValid || automation.status !== 'idle'} 100 | style={{float: 'right', marginLeft: 12}} 101 | onClick={openInitAutoModal} 102 | > 103 | <Icon name="cog" /> 104 | </Button> 105 | <Button 106 | icon 107 | color="teal" 108 | disabled={!url.isValid || automation.status !== 'idle'} 109 | style={{float: 'right', marginLeft: 12}} 110 | onClick={() => { 111 | openSetHashModal(); 112 | }} 113 | > 114 | <Icon name="sign in alternate" /> 115 | </Button> 116 | <Button 117 | icon 118 | color="blue" 119 | disabled={!url.isValid || automation.status !== 'idle'} 120 | style={{float: 'right'}} 121 | onClick={() => { 122 | hashAction({type: H_RANDOM}); 123 | }} 124 | > 125 | <Icon name="random" /> 126 | </Button> 127 | </Grid.Column> 128 | </Grid> 129 | <Segment 130 | inverted 131 | style={{ 132 | marginTop: 18, 133 | maxHeight: 'calc(100vh - 150px)', 134 | overflow: 'auto', 135 | background: '#222', 136 | padding: 20, 137 | }} 138 | > 139 | {numberSlider} 140 | <Divider /> 141 | <Segment.Group>{hashSliders}</Segment.Group> 142 | </Segment> 143 | <Segment 144 | style={{ 145 | padding: 0, 146 | margin: 0, 147 | paddingTop: 2, 148 | paddingBottom: 4, 149 | paddingLeft: 15, 150 | marginTop: 15, 151 | cursor: 'pointer', 152 | userSelect: 'none', 153 | background: '#CCC', 154 | }} 155 | onClick={() => { 156 | navigator.clipboard.writeText(hash.hash); 157 | }} 158 | > 159 | <span style={{fontFamily: 'monospace', fontSize: 11}}>{hash.hash}</span> 160 | <Icon color="grey" name="copy" size="small" style={{float: 'right', marginRight: 10, marginTop: 6}} /> 161 | </Segment> 162 | </> 163 | ); 164 | }; 165 | 166 | export default Controls; 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # token-art-tools 2 | 3 | Static webapp for generative artists to explore a script's creative domain via sliders mapped to hashpairs, automate image generation for a sample set, and capture features as a CSV for analyzing probability of outcomes. Developed in React using Gatsby and Semantic UI libraries. 4 | 5 | https://owmo-dev.github.io/token-art-tools/ 6 | 7 | ![screenshot](assets/preview.jpg) 8 | 9 | # Project Configuration 10 | 11 | This webapp expects a `localhost` web server hosting your script and that you have referenced the `lib/connector.js` script before executing your sketch. Global variables `hash` and `number` are available for your sketch to use, as well as some platform sepcific implementations. 12 | 13 | ## Boilerplate Setup 14 | 15 | The following boilerplate project setup supports all of Token Art Tool's features 16 | 17 | https://github.com/owmo-dev/token-art-tools-boilerplate 18 | 19 | ## Manual Setup 20 | 21 | The `lib/connector.js` script must be referenced in your project before your artwork sketch executes. Either copy it into your repo or use this CDN. 22 | 23 | ```html 24 | <script src="https://cdn.jsdelivr.net/gh/owmo-dev/token-art-tools@1.6.6/lib/connector.js"></script> 25 | ``` 26 | 27 | ## Host Locally 28 | 29 | You can run this webapp locally if you want by doing the following: 30 | 31 | 1. `npm install` 32 | 2. `make run-server` 33 | 3. `http://localhost:8000` 34 | 35 | ## Platform Specific Features 36 | 37 | ### [Art Blocks](https://www.artblocks.io) 38 | 39 | The global variable `tokenData` is made available by the `lib/connector.js` script by using the hash directly provided by the webapp. All 32 hashpairs are used in the hash and the edition number simulates project "0" with a possitble edition range of "0 to 1000", smaller than the possible million for practical UI purposes. 40 | 41 | ```js 42 | tokenData = { 43 | hash: '0x0000000000000000000000000000000000000000000000000000000000000000', 44 | tokenId: 1000000, 45 | }; 46 | ``` 47 | 48 | Please refer to [Art Block's 101 Docs](https://docs.artblocks.io/creator-docs/creator-onboarding/readme/) for more information. 49 | 50 | ### [fx(hash)](https://www.fxhash.xyz) 51 | 52 | The global variable `fxhash` and function `fxrand` are made available by the `lib/connector.js` script by using a slice of the hash provided by the webapp. The script simply overrides the code snippet required by the fx(hash) creator minting process. If you are including this snippet in your project setup (recommended), please ensure that the reference to `lib/connector.js` is made **AFTER** the fx(hash) code snippet to have them properly overriden. Also, please don't forget to remove it when you are ready to mint. 53 | 54 | ```js 55 | fxhash = 'oo89fd946ca9ce6b038b4434c205da26767bf632748f5cf8292'; 56 | 57 | console.log('new random number between 0 and 1', fxrand()); 58 | ``` 59 | 60 | Please refer to the [fxhash publish docs](https://www.fxhash.xyz/doc/artist/guide-publish-generative-token) for more inforamtion. 61 | 62 | ## Technical Requirements 63 | 64 | ### Canvas is Required 65 | 66 | Artwork must be displayed within a `canvas` element for all features to work as expected. 67 | 68 | ### preserveDrawingBuffer: true 69 | 70 | The `preserveDrawingBuffer` must be `true` for screenshots to work. 71 | 72 | ##### ThreeJS 73 | 74 | ```javascript 75 | let renderer = new THREE.WebGLRenderer({preserveDrawingBuffer: true}); 76 | ``` 77 | 78 | ##### WebGL 79 | 80 | ```javascript 81 | const gl = canvas.getContext('webgl', {preserveDrawingBuffer: true}); 82 | ``` 83 | 84 | # Tips & Tricks 85 | 86 | ## Use HTTPS 87 | 88 | Some browsers will require that you serve your artwork locally via `https` rather than `http` 89 | 90 | ## Share Hosted URL & Hash 91 | 92 | If your `URL` is publicly accessible, you can share a Token Art Tools initialization by using `url`, `hash` and `number` variables in the URL for the application (click the "shared" button next to the address bar to copy the current to clipboard). Not that a valid `url` is required for anything to be set. 93 | 94 | `https://owmo-dev.github.io/token-art-tools//?url={URL}&hash={HASH}&number={NUMBER}` 95 | 96 | ## Hashpairs for Exploration 97 | 98 | The hashpair sliders are best used early on while exploring ranges and mixes of different creative features. 99 | 100 | ```js 101 | function mpd(n, a1, b1, a2, b2) { 102 | return ((n - a1) / (b1 - a1)) * (b2 - a2) + a2; 103 | } 104 | 105 | let hs = []; 106 | 107 | for (j = 0; j < 32; j++) { 108 | hs.push(hash.slice(2 + j * 2, 4 + j * 2)); 109 | } 110 | 111 | let rns = hs.map(x => { 112 | return parseInt(x, 16); 113 | }); 114 | 115 | let features = { 116 | hue: mpd(rns[0], 0, 255, 0, 360), 117 | size: mpd(rns[1], 0, 255, 0.5, 1.8), 118 | offset: mpd(rns[2], 0, 255, -2.0, 2.0), 119 | }; 120 | ``` 121 | 122 | ## Hash to Seed Randomd 123 | 124 | While I have used hashparis directly in projects, I wouldn't recommend it because the hash produced by external services (such as minting on chain) may not produce a sufficient randomization and it's more difficult to control probabilities. The best way to use the `hash` is simply to use it as a seed in a random function you trust. 125 | 126 | Below is an excellent Random function [Piter Pasma](https://twitter.com/piterpasma) made available for everyone to use. 127 | 128 | ```js 129 | let S = Uint32Array.from([0, 0, 0, 0]).map(i => parseInt(hash.substr(i * 8 + 5, 8), 16)); 130 | 131 | let R = (a = 1) => { 132 | let t = S[3]; 133 | S[3] = S[2]; 134 | S[2] = S[1]; 135 | let s = (S[1] = S[0]); 136 | t ^= t << 11; 137 | S[0] ^= t ^ (t >>> 8) ^ (s >>> 19); 138 | return (a * S[0]) / 2 ** 32; 139 | }; 140 | 141 | console.log('random value between 0 and 1', R()); 142 | 143 | let myArray = ['a', 'b', 'c', 'd']; 144 | 145 | console.log('pick from array', myArray[(R() * myArray.length) | 0]); 146 | ``` 147 | 148 | ## Longer Delays for Reliable Screenshots & CSV Capture 149 | 150 | The automated process can sometimes produce unreliable results, especially if your artwork is particularly taxing. I simply suggest increasing the wait time between capturing and testing on smaller sample sizes before commiting to a larger set to run overnight. 151 | 152 | ## Define Features as Early as Possible 153 | 154 | The `lib/connector.js` script defines a global `features` variable as an empty object which you can then assign key-value pairs to display in the webapp. You must set the features variables no later than `500ms` because the webapp will attempt to retrieve them at about `600ms`. 155 | 156 | ```js 157 | features = { 158 | Palette: 'Blue Sky', 159 | Style: 'Shadow', 160 | }; 161 | features['Size'] = 10; 162 | ``` 163 | 164 | You can only assign `int`, `float`, and `string` values as a feature entry. 165 | 166 | # Known Issues 167 | 168 | - When using a simple web server (ex: `python -m http.server 5500`), Chrome will block HTML files within iframes. At the time of writing, Firefox will still allow this, but it's much better to simple use a `node` project setup. 169 | -------------------------------------------------------------------------------- /src/components/inputs/range-slider.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, memo, forwardRef} from 'react'; 2 | import {Segment, Grid, Button} from 'semantic-ui-react'; 3 | import {RangeStepInput} from 'react-range-step-input'; 4 | 5 | import {useURL} from '../../hooks/useURL'; 6 | import {H_LOCK, H_LOCK_NUM, H_SET, H_SET_VALUE, useHash} from '../../hooks/useHash'; 7 | import {useAutomation} from '../../hooks/useAutomation'; 8 | 9 | import {clamp} from '../../helpers/math'; 10 | import {iota} from '../../helpers/iota'; 11 | 12 | export const {TYPE_HASH, TYPE_NUMBER} = iota(); 13 | 14 | function withStateSlice(Comp, slice) { 15 | const MemoComp = memo(Comp); 16 | function Wrapper(props, ref) { 17 | const state = useHash(); 18 | return <MemoComp ref={ref} state={slice(state, props)} {...props} />; 19 | } 20 | Wrapper.displayName = `withStateSlice(${Comp.displayName || Comp.name})`; 21 | return memo(forwardRef(Wrapper)); 22 | } 23 | 24 | function SliderControl({index, min, max, step, type}) { 25 | const [url] = useURL(); 26 | const [hash, hashAction] = useHash(); 27 | const [automation] = useAutomation(); 28 | 29 | const [value, setValue] = useState(0); 30 | const [locked, setLocked] = useState(false); 31 | 32 | useEffect(() => { 33 | if (type === TYPE_HASH) { 34 | if (hash.values[index] !== value) { 35 | setValue(hash.values[index]); 36 | } 37 | } else if (type === TYPE_NUMBER) { 38 | if (hash.number !== value) { 39 | setValue(hash.number); 40 | } 41 | } 42 | }, [hash]); 43 | 44 | useEffect(() => { 45 | if (type === TYPE_HASH) { 46 | if (value !== hash.values[index]) { 47 | hashAction({type: H_SET_VALUE, index: index, value: value}); 48 | } 49 | } else if (type === TYPE_NUMBER) { 50 | if (value !== hash.number) { 51 | hashAction({type: H_SET, number: value}); 52 | } 53 | } 54 | }, [value]); 55 | 56 | useEffect(() => { 57 | if (type === TYPE_HASH) { 58 | if (locked !== hash.locked[index]) { 59 | setLocked(hash.locked[index]); 60 | } 61 | } else if (type === TYPE_NUMBER) { 62 | if (locked !== hash.numberLocked) { 63 | setLocked(hash.numberLocked); 64 | } 65 | } 66 | }, [hash.locked[index], hash.numberLocked]); 67 | 68 | const handleChange = e => { 69 | const v = parseInt(e.target.value); 70 | setValue(v); 71 | }; 72 | 73 | const stepValue = inc => { 74 | if (type === TYPE_HASH) { 75 | setValue(clamp(value + inc, hash.params.min, hash.params.max)); 76 | } else { 77 | setValue(clamp(value + inc, hash.params.start, hash.params.editions)); 78 | } 79 | }; 80 | 81 | return ( 82 | <Segment inverted style={{background: '#222', marginBottom: 8, padding: 0}}> 83 | <Grid> 84 | <Grid.Column width={1}> 85 | <span 86 | style={{ 87 | fontFamily: 'monospace', 88 | fontSize: 16, 89 | position: 'relative', 90 | top: 5, 91 | left: -3, 92 | userSelect: 'none', 93 | }} 94 | > 95 | {index} 96 | </span> 97 | </Grid.Column> 98 | <Grid.Column width={2}> 99 | <Button 100 | circular 101 | icon="minus" 102 | size="mini" 103 | onClick={() => { 104 | stepValue(type === TYPE_HASH ? -16 : type === TYPE_NUMBER ? -10 : 0); 105 | }} 106 | disabled={locked || !url.isValid || automation.status !== 'idle'} 107 | /> 108 | </Grid.Column> 109 | <Grid.Column width={8}> 110 | <span style={{top: 4, position: 'relative'}}> 111 | <RangeStepInput 112 | min={min} 113 | max={max} 114 | step={step} 115 | onChange={handleChange} 116 | value={value} 117 | style={{width: '100%'}} 118 | disabled={locked || !url.isValid || automation.status !== 'idle'} 119 | /> 120 | </span> 121 | </Grid.Column> 122 | <Grid.Column width={2}> 123 | <Button 124 | circular 125 | icon="plus" 126 | size="mini" 127 | onClick={() => { 128 | stepValue(type === TYPE_HASH ? 16 : type === TYPE_NUMBER ? 10 : 0); 129 | }} 130 | disabled={locked || !url.isValid || automation.status !== 'idle'} 131 | /> 132 | </Grid.Column> 133 | <Grid.Column width={1}> 134 | <span 135 | style={ 136 | type === TYPE_HASH 137 | ? { 138 | fontFamily: 'monospace', 139 | fontSize: 16, 140 | position: 'relative', 141 | top: 5, 142 | left: -10, 143 | userSelect: 'none', 144 | } 145 | : { 146 | fontFamily: 'monospace', 147 | fontSize: 16, 148 | position: 'absolute', 149 | width: 50, 150 | height: 20, 151 | top: 18, 152 | left: -10, 153 | textAlign: 'center', 154 | } 155 | } 156 | > 157 | {hash.hash !== undefined 158 | ? type === TYPE_HASH 159 | ? hash.hash[index * 2 + 2] + hash.hash[index * 2 + 3] 160 | : type === TYPE_NUMBER 161 | ? hash.number 162 | : '-' 163 | : 'ER'} 164 | </span> 165 | </Grid.Column> 166 | <Grid.Column width={2}> 167 | <Button 168 | size="tiny" 169 | icon={locked ? 'lock' : 'unlock'} 170 | onClick={() => { 171 | if (type === TYPE_HASH) hashAction({type: H_LOCK, index: index}); 172 | if (type === TYPE_NUMBER) hashAction({type: H_LOCK_NUM}); 173 | }} 174 | disabled={!url.isValid || automation.status !== 'idle'} 175 | color={locked ? 'red' : null} 176 | /> 177 | </Grid.Column> 178 | </Grid> 179 | </Segment> 180 | ); 181 | } 182 | const RangeSlider = withStateSlice(SliderControl, (state, {index}) => state.values[index]); 183 | 184 | export default RangeSlider; 185 | -------------------------------------------------------------------------------- /src/components/panels/viewer.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react'; 2 | import {Input, Button, Icon, Dropdown, Segment} from 'semantic-ui-react'; 3 | 4 | import {H_CLEAR, H_SET, useHash} from '../../hooks/useHash'; 5 | import {useURL, U_CLEAR, U_REFRESH, U_SET} from '../../hooks/useURL'; 6 | import {F_CLEAR, useFeatures} from '../../hooks/useFeatures'; 7 | import {useAutomation} from '../../hooks/useAutomation'; 8 | 9 | import SetResolution from '../modals/set-resolution'; 10 | import Instructions from '../copy/instructions'; 11 | import Features from '../info/features'; 12 | 13 | import {screenshot} from '../../helpers/screenshot'; 14 | import {isValidHash} from '../../helpers/hash'; 15 | 16 | const Viewer = () => { 17 | const [hash, hashAction] = useHash(); 18 | const [url, urlAction] = useURL(); 19 | const [, featuresAction] = useFeatures(); 20 | const [automation] = useAutomation(); 21 | 22 | const [resolutionValue, setResolutionValue] = useState('fill'); 23 | const [iframeResolution, setIFrameResolution] = useState({x: '100%', y: '100%'}); 24 | 25 | useEffect(() => { 26 | const params = new URLSearchParams(window.location.search); 27 | 28 | const ext_url = params.get('url'); 29 | if (ext_url !== null && ext_url !== '') { 30 | urlAction({type: U_SET, url: ext_url}); 31 | 32 | const hashData = {}; 33 | 34 | const set_hash = params.get('hash'); 35 | if (set_hash !== null) { 36 | if (isValidHash(set_hash)) { 37 | hashData['hash'] = set_hash; 38 | } 39 | } 40 | 41 | const set_number = params.get('number'); 42 | if (set_number !== null) { 43 | const num = Number(set_number); 44 | if (Number.isInteger(num) && num >= hash.params.start && num <= hash.params.editions) { 45 | hashData['number'] = num; 46 | } 47 | } 48 | 49 | if (Object.keys(hashData).length > 0) { 50 | hashAction({type: H_SET, ...hashData}); 51 | } 52 | } 53 | }, []); 54 | 55 | function onChange(e) { 56 | urlAction({type: U_SET, url: e.target.value}); 57 | } 58 | 59 | function handleClearURL() { 60 | urlAction({type: U_CLEAR}); 61 | hashAction({type: H_CLEAR}); 62 | featuresAction({type: F_CLEAR}); 63 | setResolutionValue('fill'); 64 | } 65 | 66 | const resolutionOptions = [ 67 | { 68 | key: 'fill', 69 | text: 'Fill Available', 70 | value: 'fill', 71 | }, 72 | { 73 | key: 'detailed', 74 | text: 'Detailed', 75 | value: 'detailed', 76 | }, 77 | { 78 | key: 'preview', 79 | text: 'Preview', 80 | value: 'preview', 81 | }, 82 | { 83 | key: 'thumb', 84 | text: 'Thumbnail', 85 | value: 'thumb', 86 | }, 87 | { 88 | key: 'custom', 89 | text: 'Custom Resolution', 90 | value: 'custom', 91 | }, 92 | ]; 93 | 94 | function handleChange(e, d) { 95 | if (d.value === 'custom') { 96 | openResolutionModal(); 97 | } else { 98 | setResolutionValue(d.value); 99 | urlAction({type: U_REFRESH}); 100 | } 101 | } 102 | 103 | useEffect(() => { 104 | switch (resolutionValue) { 105 | case 'custom': 106 | break; 107 | case 'thumb': 108 | setIFrameResolution({x: '258px', y: '258px'}); 109 | break; 110 | case 'preview': 111 | setIFrameResolution({x: '514px', y: '514px'}); 112 | break; 113 | case 'detailed': 114 | setIFrameResolution({x: '1026px', y: '1026px'}); 115 | break; 116 | default: 117 | case 'fill': 118 | setIFrameResolution({x: '100%', y: '100%'}); 119 | break; 120 | } 121 | }, [resolutionValue]); 122 | 123 | const [isResolutionModalOpen, setResolutionModalState] = useState(false); 124 | 125 | function openResolutionModal() { 126 | setResolutionModalState(true); 127 | } 128 | 129 | function closeResolutionModal() { 130 | setResolutionModalState(false); 131 | } 132 | 133 | function setRes(x, y) { 134 | setResolutionValue('custom'); 135 | setIFrameResolution({ 136 | x: x + 2 + 'px', 137 | y: y + 2 + 'px', 138 | }); 139 | urlAction({type: U_REFRESH}); 140 | } 141 | 142 | return ( 143 | <> 144 | <SetResolution active={isResolutionModalOpen} close={closeResolutionModal} set={setRes} /> 145 | <div style={{width: 'auto', height: 50, marginBottom: 10, marginTop: 10}}> 146 | <Button 147 | icon 148 | disabled={!url.isValid || automation.status !== 'idle'} 149 | style={{float: 'right', marginLeft: 20}} 150 | onClick={() => { 151 | screenshot(hash.hash); 152 | }} 153 | > 154 | <Icon name="camera" /> 155 | </Button> 156 | <Button 157 | icon 158 | disabled={!url.isValid || automation.status !== 'idle'} 159 | floated="right" 160 | style={{float: 'right', marginLeft: 20}} 161 | onClick={() => { 162 | urlAction({type: U_REFRESH}); 163 | }} 164 | > 165 | <Icon name="refresh" /> 166 | </Button> 167 | <Dropdown 168 | selection 169 | placeholder="Fit Available Space" 170 | disabled={!url.isValid || automation.status !== 'idle'} 171 | options={resolutionOptions} 172 | value={resolutionValue} 173 | onChange={handleChange} 174 | style={{float: 'right', marginLeft: 20}} 175 | /> 176 | <Button 177 | icon 178 | disabled={!url.isValid || automation.status !== 'idle'} 179 | floated="right" 180 | style={{float: 'right', marginLeft: 20}} 181 | onClick={() => { 182 | navigator.clipboard.writeText(`${window.location.origin}/token-art-tools/?url=${url.url}&hash=${hash.hash}&number=${hash.number}`); 183 | }} 184 | > 185 | <Icon name="share square" /> 186 | </Button> 187 | <Input 188 | label="URL" 189 | fluid 190 | placeholder="http://127.0.0.1:5500" 191 | onChange={onChange} 192 | disabled={automation.status !== 'idle'} 193 | value={url.url} 194 | style={{height: 38}} 195 | icon={ 196 | url.url !== '' ? ( 197 | <Icon 198 | name="delete" 199 | link 200 | onClick={() => { 201 | handleClearURL(); 202 | }} 203 | /> 204 | ) : null 205 | } 206 | /> 207 | </div> 208 | <div 209 | style={{ 210 | width: '100%', 211 | height: 'calc(100vh - 188px)', 212 | position: 'relative', 213 | border: '1px solid #00000044', 214 | marginBottom: 15, 215 | }} 216 | > 217 | <Button 218 | icon 219 | inverted 220 | disabled={!url.isValid || automation.status !== 'idle'} 221 | size="mini" 222 | onClick={() => { 223 | var iframe = window.document.querySelector('iframe'); 224 | const w = iframe.clientWidth; 225 | const h = iframe.clientHeight; 226 | window.open(url.url + '?hash=' + hash.hash + '&number=' + hash.number, '', `top=100, width=${w}, height=${h}`); 227 | }} 228 | style={{ 229 | position: 'absolute', 230 | bottom: 0, 231 | right: 0, 232 | marginBottom: 10, 233 | marginRight: 10, 234 | zIndex: 1000, 235 | opacity: '0.5', 236 | }} 237 | > 238 | <Icon name="external" /> 239 | </Button> 240 | <div 241 | style={{ 242 | width: '100%', 243 | height: '100%', 244 | overflow: 'auto', 245 | position: 'relative', 246 | }} 247 | > 248 | {url.isValid && hash.hash !== undefined ? ( 249 | <div 250 | style={ 251 | resolutionValue === 'fill' 252 | ? { 253 | width: '100%', 254 | height: '100%', 255 | overflow: 'hidden', 256 | } 257 | : { 258 | position: 'absolute', 259 | top: '50%', 260 | left: '50%', 261 | transform: 'translateX(-50%) translateY(-50%)', 262 | } 263 | } 264 | > 265 | <iframe 266 | id={new Date().getTime()} 267 | title="token art tools viewer" 268 | src={url.url + '?hash=' + hash.hash + '&number=' + hash.number} 269 | width={iframeResolution.x} 270 | height={iframeResolution.y} 271 | style={{ 272 | border: '1px dashed #99999933', 273 | }} 274 | key={url.iframeKey} 275 | /> 276 | </div> 277 | ) : ( 278 | <div style={{width: '100%', height: '100%', background: '#555', overflow: 'auto'}}> 279 | <Instructions /> 280 | </div> 281 | )} 282 | </div> 283 | </div> 284 | <Segment inverted style={{width: '100%', height: 70, padding: 0, paddingBottom: 2}}> 285 | <Features /> 286 | </Segment> 287 | </> 288 | ); 289 | }; 290 | 291 | export default Viewer; 292 | --------------------------------------------------------------------------------