diff --git a/.gitignore b/.gitignore index f6f5676f..6635388d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,11 @@ web_modules/ # Optional eslint cache .eslintcache +<<<<<<< HEAD +lerna-debug.log +.vscode +======= +<<<<<<< HEAD # Microbundle cache .rpt2_cache/ @@ -274,3 +279,8 @@ fabric.properties # Documentation here: https://yarnpkg.com/features/zero-installs #!/.yarn/cache /.pnp.* +======= +lerna-debug.log +.vscode +>>>>>>> 994de2e (adjust mint page to match figma mockups) +>>>>>>> d966492f20f4b3cee4c2907137944cdd50c0f15e diff --git a/package.json b/package.json index 68f4ab07..1c8f2f8c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint-plugin-no-inline-styles": "^1.0.5", "eslint-plugin-react": "^7.31.11", "prettier": "^2.3.2", + "sass": "^1.58.0", "vite": "^4.0.0" } } diff --git a/public/assets/icons/polygon.svg b/public/assets/icons/polygon.svg new file mode 100644 index 00000000..2ee2e72a --- /dev/null +++ b/public/assets/icons/polygon.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/src/App.jsx b/src/App.jsx index 88438cad..80750dff 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import { BlinkManager } from "./library/blinkManager" import { LookAtManager } from "./library/lookatManager" import { EffectManager } from "./library/effectManager" import { AnimationManager } from "./library/animationManager" +import { ScreenshotManager } from "./library/screenshotManager" import Scene from "./components/Scene" import Background from "./components/Background" @@ -19,12 +20,14 @@ import Background from "./components/Background" import View from "./pages/View" import Save from "./pages/Save" import Load from "./pages/Load" +import Mint from "./pages/Mint" import BioPage from "./pages/Bio" import Create from "./pages/Create" import Landing from "./pages/Landing" import Appearance from "./pages/Appearance" import LanguageSwitch from "./components/LanguageSwitch" + // dynamically import the manifest const assetImportPath = import.meta.env.VITE_ASSET_PATH + "/manifest.json" const peresonalityImportPath = @@ -109,6 +112,7 @@ async function fetchAll() { const blinkManager = new BlinkManager(0.1, 0.1, 0.5, 5) const lookatManager = new LookAtManager(80, "editor-scene") const effectManager = new EffectManager() + const screenshotManager = new ScreenshotManager() return { initialManifest, @@ -117,6 +121,7 @@ async function fetchAll() { blinkManager, lookatManager, effectManager, + screenshotManager, } } @@ -158,6 +163,7 @@ export default function App() { blinkManager, lookatManager, effectManager, + screenshotManager, } = resource.read() const [hideUi, setHideUi] = useState(false) @@ -174,16 +180,19 @@ export default function App() { moveCamera, setManifest, manifest, + model, } = useContext(SceneContext) const { viewMode } = useContext(ViewContext) effectManager.camera = camera effectManager.scene = scene + screenshotManager.scene = scene + const updateCameraPosition = () => { if (!effectManager.camera) return - if ([ViewMode.BIO, ViewMode.MINT, ViewMode.CHAT].includes(viewMode)) { + if ([ViewMode.BIO, ViewMode.CHAT].includes(viewMode)) { // auto move camera if (viewMode === ViewMode.CHAT) { cameraDistance = cameraDistanceChat @@ -239,7 +248,7 @@ export default function App() { if (controls) { if ( - [ViewMode.APPEARANCE, ViewMode.SAVE, ViewMode.MINT].includes(viewMode) + [ViewMode.APPEARANCE, ViewMode.SAVE].includes(viewMode) ) { controls.enabled = true } else { @@ -263,6 +272,30 @@ export default function App() { }) } + const getFaceScreenshot = (width = 256, height = 256, getBlob = false) => { + blinkManager.enableScreenshot(); + model.traverse(o => { + if (o.isSkinnedMesh) { + const headBone = o.skeleton.bones.filter(bone => bone.name === 'head')[0]; + headBone.getWorldPosition(localVector3); + } + }); + const headPosition = localVector3; + const female = templateInfo.name === "Drophunter"; + const cameraFov = female ? 0.78 : 0.85; + screenshotManager.setCamera(headPosition, cameraFov); + //let imageName = "AvatarImage_" + Date.now() + ".png"; + + //const screenshot = screenshotManager.saveAsImage(imageName); + const screenshot = getBlob ? + screenshotManager.getScreenshotBlob(width, height): + screenshotManager.getScreenshotTexture(width, height); + blinkManager.disableScreenshot(); + animationManager.disableScreenshot(); + + return screenshot; + } + // map current app mode to a page const pages = { [ViewMode.LANDING]: , @@ -280,8 +313,8 @@ export default function App() { ), [ViewMode.CREATE]: , [ViewMode.LOAD]: , - // [ViewMode.MINT]: , - [ViewMode.SAVE]: , + [ViewMode.MINT]: , + [ViewMode.SAVE]: , [ViewMode.CHAT]: , } diff --git a/src/components/ExportMenu.jsx b/src/components/ExportMenu.jsx index 841e6c02..84035982 100644 --- a/src/components/ExportMenu.jsx +++ b/src/components/ExportMenu.jsx @@ -9,7 +9,7 @@ import { LanguageContext } from "../context/LanguageContext" const defaultName = "Anon" -export const ExportMenu = () => { +export const ExportMenu = ({getFaceScreenshot}) => { // Translate hook const { t } = useContext(LanguageContext); const [name] = React.useState(localStorage.getItem("name") || defaultName) @@ -44,7 +44,8 @@ export const ExportMenu = () => { size={14} className={styles.button} onClick={() => { - downloadVRM(model, avatar, name, 4096, true) + const screenshot = getFaceScreenshot(); + downloadVRM(model, avatar, name, screenshot, 4096, true) }} /> diff --git a/src/components/Mint.jsx b/src/components/Mint.jsx deleted file mode 100644 index 7f494fc0..00000000 --- a/src/components/Mint.jsx +++ /dev/null @@ -1,269 +0,0 @@ -import axios from "axios" -import { BigNumber, ethers } from "ethers" -import React, { Fragment, useContext, useState, useEffect } from "react" -import ethereumIcon from "../../public/ui/mint/ethereum.png" -import mintPopupImage from "../../public/ui/mint/mintPopup.png" -import { AccountContext } from "../context/AccountContext" -import { SceneContext } from "../context/SceneContext" -import { getModelFromScene, getCroppedScreenshot } from "../library/utils" -import { CharacterContract, EternalProxyContract, webaverseGenesisAddress } from "./Contract" -import { getGLBBlobData } from "../library/download-utils" -import styles from "./Mint.module.css" - -const pinataApiKey = import.meta.env.VITE_PINATA_API_KEY -const pinataSecretApiKey = import.meta.env.VITE_PINATA_API_SECRET - -const mintCost = 0.01 - -export default function MintPopup({screenshotPosition}) { - const { avatar, skinColor, model, templateInfo } = useContext(SceneContext) - const [mintStatus, setMintStatus] = useState("") - const [tokenPrice, setTokenPrice] = useState(null); - const chainId = "0x89"; - - useEffect(() => { - ( async () => { - const defaultProvider = new ethers.providers.StaticJsonRpcProvider('https://polygon-rpc.com/') - const contract = new ethers.Contract(CharacterContract.address, CharacterContract.abi, defaultProvider) - - const tp = await contract.tokenPrice() - setTokenPrice( BigNumber.from(tp).mul(1) ) - })(); - }, []) - - const connectWallet = async () => { - if (window.ethereum) { - try { - const chain = await window.ethereum.request({ method: 'eth_chainId' }) - if (parseInt(chain, 16) == parseInt(chainId, 16)) { - const addressArray = await window.ethereum.request({ - method: 'eth_requestAccounts', - }) - return addressArray.length > 0 ? addressArray[0] : "" - } else { - window.ethereum.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: chainId }], - }) - const addressArray = await window.ethereum.request({ - method: 'eth_requestAccounts', - }) - return addressArray.length > 0 ? addressArray[0] : "" - } - } catch (err) { - return ""; - } - } else { - return ""; - } - } - - async function saveFileToPinata(fileData, fileName) { - if (!fileData) return console.warn("Error saving to pinata: No file data") - const url = `https://api.pinata.cloud/pinning/pinFileToIPFS` - let data = new FormData() - - data.append("file", fileData, fileName) - let resultOfUpload = await axios.post(url, data, { - maxContentLength: "Infinity", //this is needed to prevent axios from erroring out with large files - maxBodyLength: "Infinity", //this is needed to prevent axios from erroring out with large files - headers: { - "Content-Type": `multipart/form-data; boundary=${data._boundary}`, - pinata_api_key: pinataApiKey, - pinata_secret_api_key: pinataSecretApiKey, - }, - }) - return resultOfUpload.data - } - - const getAvatarTraits = () => { - let metadataTraits = [] - Object.keys(avatar).map((trait) => { - if (Object.keys(avatar[trait]).length !== 0) { - metadataTraits.push({ - trait_type: trait, - value: avatar[trait].name, - }) - } - }) - return metadataTraits - } - - const mintAsset = async (avatar) => { - let walletAddress = await connectWallet() - - const pass = await checkOT(walletAddress); - if(pass) { - setMintStatus("Uploading...") - let imageHash, glbHash; - const screenshot = await getCroppedScreenshot("editor-scene",screenshotPosition.x, screenshotPosition.y, screenshotPosition.width, screenshotPosition.height, true) - if (screenshot) { - let imageName = "AvatarImage_" + Date.now() + ".png"; - imageHash = await (async() => { - for (let i = 0; i < 10; i++) { // hack: give it a few tries, sometimes uploading to pinata fail for some reason - try { - const img_hash = await saveFileToPinata( - screenshot, - imageName - ).catch((reason) => { - console.error(i, "---", reason) - }) - return img_hash - } catch(err) { - console.warn(err); - } - } - throw new Error('failed to upload screenshot'); - setMintStatus("Couldn't save screenshot to pinata") - })(); - } else { - throw new Error("Unable to get screenshot") - } - - const glb = await getGLBBlobData(model) - if (glb) { - let glbName = "AvatarGlb_" + Date.now() + ".glb"; - glbHash = await (async() => { - for (let i = 0; i < 10; i++) { // hack: give it a few tries, sometimes uploading to pinata fail for some reason - try { - const glb_hash = await saveFileToPinata( - glb, - glbName - ).catch((reason) => { - console.error(i, "---", reason) - setMintStatus("Couldn't save glb to pinata") - }) - return glb_hash - } catch(err) { - console.warn(err); - } - } - throw new Error('failed to upload glb'); - setMintStatus("Couldn't save glb to pinata") - })(); - } else { - throw new Error("Unable to get glb") - } - - const attributes = getAvatarTraits() - const metadata = { - name: "Avatars", - description: "Character Studio Avatars.", - image: `ipfs://${imageHash.IpfsHash}`, - animation_url: `ipfs://${glbHash.IpfsHash}`, - attributes: attributes - } - const str = JSON.stringify(metadata) - const metaDataHash = await saveFileToPinata( - new Blob([str]), - "AvatarMetadata_" + Date.now() + ".json", - ) - const metadataIpfs = `ipfs://${metaDataHash.IpfsHash}` - - setMintStatus("Minting...") - const signer = new ethers.providers.Web3Provider( - window.ethereum, - ).getSigner() - const contract = new ethers.Contract(CharacterContract.address, CharacterContract.abi, signer) - try { - const options = { - value: tokenPrice, - from: walletAddress - } - const tx = await contract.mintToken(1, metadataIpfs, options) - let res = await tx.wait() - if (res.transactionHash) { - setMintStatus("Mint success!") - } - } catch (err) { - setMintStatus("Public Mint failed! Please check your wallet.") - } - } else { - return; - } - } - - const takeScreenshot = async () => { - const img = await getCroppedScreenshot("editor-scene",screenshotPosition.x, screenshotPosition.y, screenshotPosition.width, screenshotPosition.height, true) - const glb = await getGLBBlobData(model) - } - - const checkOT = async (address) => { - if(address) { - const address = '0x6e58309CD851A5B124E3A56768a42d12f3B6D104' - const ethersigner = ethers.getDefaultProvider("mainnet", { - alchemy: import.meta.env.VITE_ALCHEMY_API_KEY, - }) - const contract = new ethers.Contract(EternalProxyContract.address, EternalProxyContract.abi, ethersigner); - const webaBalance = await contract.beneficiaryBalanceOf(address, webaverseGenesisAddress, 1); - if(parseInt(webaBalance) > 0) return true; - else { - setMintStatus("Currently in alpha. You need a genesis pass to mint. \n Will be public soon!") - return false; - } - } else { - setMintStatus("Please connect your wallet") - return false; - } - } - - const showTrait = (trait) => { - if (trait.name in avatar) { - if ("traitInfo" in avatar[trait.name]) { - return avatar[trait.name].name - } else return "Default " + trait.name - } else return "No set" - } - - return ( - // currentView.includes("MINT") && ( -
-
- {/* {connected && ( */} - -
- -
Mint Avatar
-
-
- {templateInfo.traits && - templateInfo.traits.map((item, index) => ( -
-
- -
{showTrait(item)}
-
- ))} -
-
-
- {"Mint Price: "} -
-
- -
-  {mintCost} -
-
-
- {mintStatus} -
-
-
mintAsset(model)} - > - Mint -
-
- - {/* )} */} -
-
- // ) - ) -} diff --git a/src/components/Resizable.jsx b/src/components/Resizable.jsx deleted file mode 100644 index 08ad41c3..00000000 --- a/src/components/Resizable.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import React, { useState } from 'react'; -import styles from "./Resizable.module.css" - -const ResizableDiv = ({setScreenshotPosition, screenshotPosition}) => { - - const [initialPos, setInitialPos] = useState(null); - const [initialSize, setInitialSize] = useState(null); - - const [moving, setMoving] = useState(false) - const [scaling, setScaling] = useState(false) - - - const windowResize = () => { - updateMask() - } - - - - React.useEffect (() => { - let resizable = document.getElementById('screenshots'); - let draggable = document.getElementById('Screenshot-block'); - - resizable.style.width = `${screenshotPosition.width}px`; - resizable.style.height = `${screenshotPosition.height}px`; - - draggable.style.paddingTop = `${screenshotPosition.y+20}px` - draggable.style.paddingLeft = `${screenshotPosition.x}px` - - - updateMask() - window.addEventListener('resize', windowResize); - return () => { - window.removeEventListener('resize', windowResize) - } - },[]) - - - - const handleMouseMove = (e) => { - //console.log(moving) - if (moving){ - let draggable = document.getElementById('Screenshot-block'); - let resizable = document.getElementById('screenshots'); - - const posX = (e.clientX - resizable.offsetWidth/2)// + initialPos.x - const posY = (e.clientY - resizable.offsetHeight/2)// + initialPos.y - - draggable.style.paddingTop = `${posY+20}px` - - draggable.style.paddingLeft = `${posX}px` - - //moveMask(posX, posY, resizable.offsetWidth, resizable.offsetHeight); - updateMask(); - setScreenshotPosition({...screenshotPosition, ...{x:posX, y:posY}}); - - } - - if (scaling){ - let resizable = document.getElementById('screenshots'); - - const newWidth = parseInt(initialSize.width) + parseInt(e.clientX - initialPos.x) - const newHeight = parseInt(initialSize.height) + parseInt(e.clientY - initialPos.y) - - const sameRatio = newWidth >= newHeight ? newWidth : newHeight - if (newWidth > 50 && newHeight > 50){ - resizable.style.width = `${sameRatio}px`; - resizable.style.height = `${sameRatio}px`; - - //resizeMask() - updateMask(); - - setScreenshotPosition({...screenshotPosition,...{width:sameRatio, height:sameRatio}}); - } - } - } - const updateMask = () =>{ - const fscreen = document.getElementById('fscreen-div'); - - const maskLeft = document.getElementById('maskLeft'); - const maskTop = document.getElementById('maskTop'); - const maskRight = document.getElementById('maskRight'); - const maskBottom = document.getElementById('maskBottom'); - - const clientHeight = fscreen.clientHeight; - const clientWidth = fscreen.clientWidth; - - maskLeft.style.width = `${screenshotPosition.x}px` - - maskTop.style.left = `${screenshotPosition.x}px` - maskTop.style.height = `${screenshotPosition.y}px` - maskTop.style.width = `${screenshotPosition.width}px` - - maskRight.style.width = `${clientWidth - screenshotPosition.x - screenshotPosition.width}px` - - maskBottom.style.left = `${screenshotPosition.x}px` - maskBottom.style.height = `${clientHeight - screenshotPosition.y - screenshotPosition.height}px` - maskBottom.style.width = `${screenshotPosition.width}px` - } - - const initialFrame = (e) => { - setMoving(true) - let draggable = document.getElementById('Screenshot-block'); - let resizable = document.getElementById('screenshots'); - - const leftPos = e.clientX - draggable.style.paddingLeft; - - draggable.style.paddingTop = e.clientY - resizable.offsetHeight/2; - - - setInitialPos({x:leftPos, y:e.clientY}); - - } - - const initial = (e) => { - setScaling(true) - let resizable = document.getElementById('screenshots'); - - setInitialPos({x:e.clientX, y:e.clientY}); - setInitialSize({width:resizable.offsetWidth, height:resizable.offsetHeight}); - - } - - const endInteraction = () => { - setScaling(false) - setMoving(false) - } - - return( -
handleMouseMove(ev)} - onMouseLeave = {endInteraction} - onMouseUp = {endInteraction} - onTouchEnd = {endInteraction} - onTouchCancel = {endInteraction}> -
-
-
-
- -
-
-
-
-
- ); - -} - -export default ResizableDiv; \ No newline at end of file diff --git a/src/components/Resizable.module.css b/src/components/Resizable.module.css deleted file mode 100644 index 4754b7cf..00000000 --- a/src/components/Resizable.module.css +++ /dev/null @@ -1,62 +0,0 @@ -.Block{ - display: flex; - align-items: flex-end; - position: absolute; - overflow: hidden; - -} -.Draggable{ - background: rgb(230, 230, 230); - cursor: pointer; - height: 20px; - width: 20px; -} -.FullScreen{ - display: flex; - overflow: hidden; - position: absolute; - width: 100%; - height: 100%; -} -.BlockScreen{ - position:relative; - width: 100%; - height: 100%; -} - -.leftBlock{ - animation: fadein 1s; - position:absolute; - background-color: rgba(0, 0, 0, 0.6); - height: 100%; -} -.rightBlock{ - animation: fadein 1s; - position:absolute; - right: 0; - background-color: rgba(0, 0, 0, 0.6); - height: 100%; -} -.topBlock{ - animation: fadein 1s; - position:absolute; - top: 0; - background-color: rgba(0, 0, 0, 0.6); - height: 25px; -} -.lowerBlock{ - animation: fadein 1s; - position:absolute; - bottom: 0; - background-color: rgba(0, 0, 0, 0.6); -} - -body { - animation: fadeInAnimation ease 3s; - animation-iteration-count: 1; - animation-fill-mode: forwards; -} -@keyframes fadein { - from { opacity: 0; } - to { opacity: 1; } -} \ No newline at end of file diff --git a/src/components/custom-button/CustomButton.module.css b/src/components/custom-button/CustomButton.module.css index e8df23e4..e9cf1770 100644 --- a/src/components/custom-button/CustomButton.module.css +++ b/src/components/custom-button/CustomButton.module.css @@ -33,6 +33,7 @@ z-index: 1; height: 100%; width: 100%; + } .iconButtonWrap .icon { @@ -133,6 +134,9 @@ text-transform: uppercase !important; line-height: 106%; } +.buttonWrap:disabled .innerWrap{ + color: #7e7e7e; +} .buttonWrap.dark .innerWrap { border: 1px solid #FFFFFF; @@ -143,24 +147,24 @@ padding: 8px 14px 6px 14px !important; } -.buttonWrap:hover .innerWrap { +.buttonWrap:hover:enabled .innerWrap { border: 1px solid #FFC000; font-family: "TTSC-Bold"; background: #FFC000; color: #EFEFEF; } -.buttonWrap:hover .innerWrap * { +.buttonWrap:hover:enabled .innerWrap * { fill: #EFEFEF !important; } -.buttonWrap:active .innerWrap { +.buttonWrap:active:enabled .innerWrap { border: 1px solid #FFC000; font-family: "TTSC-Bold"; background: #000000; color: #FFC000; } -.buttonWrap:active .innerWrap * { +.buttonWrap:active:enabled .innerWrap * { fill: #FFC000 !important; } diff --git a/src/components/custom-button/IconCollection.jsx b/src/components/custom-button/IconCollection.jsx index 5918c6fa..68363aef 100644 --- a/src/components/custom-button/IconCollection.jsx +++ b/src/components/custom-button/IconCollection.jsx @@ -123,4 +123,12 @@ export const IconCollection = [ name: 'soundoff', file: '/assets/icons/soundoff.svg', }, + { + name: 'soundoff', + file: '/assets/icons/soundoff.svg', + }, + { + name: 'polygon', + file: '/assets/icons/polygon.svg', + }, ]; \ No newline at end of file diff --git a/src/components/custom-button/index.jsx b/src/components/custom-button/index.jsx index bda4701f..c16fc3a2 100644 --- a/src/components/custom-button/index.jsx +++ b/src/components/custom-button/index.jsx @@ -26,6 +26,7 @@ export default function CustomButton(props) { onMouseEnter, active, onSubmit, + minWidth, disabled } = props const svgRef = useRef(null) @@ -123,12 +124,14 @@ export default function CustomButton(props) { className={classnames( className, styles.buttonWrap, - theme && theme === "dark" ? styles.dark : styles.light, + theme && theme === "dark" ? styles.dark : styles.light )} onClick={onClick} onMouseEnter={onMouseEnter} onSubmit={onSubmit} type={type} + disabled = {disabled} + style={{minWidth:minWidth? minWidth + "px":""}} >
)} {text && text} diff --git a/src/library/VRMExporter.js b/src/library/VRMExporter.js index 2b61cb7f..f152a027 100644 --- a/src/library/VRMExporter.js +++ b/src/library/VRMExporter.js @@ -62,7 +62,7 @@ const SPRINGBONE_COLLIDER_NAME = "vrmColliderSphere"; // const GLTF_VERSION = 2; // const HEADER_SIZE = 12; export default class VRMExporter { - parse(vrm, avatar, onDone) { + parse(vrm, avatar, screenshot, onDone) { const humanoid = vrm.humanoid; const vrmMeta = vrm.meta; const materials = vrm.materials; diff --git a/src/library/VRMExporterv0.js b/src/library/VRMExporterv0.js index fec08cac..3c96f27c 100644 --- a/src/library/VRMExporterv0.js +++ b/src/library/VRMExporterv0.js @@ -97,7 +97,7 @@ function getVRM0BoneName(name){ return name; } export default class VRMExporterv0 { - parse(vrm, avatar, onDone) { + parse(vrm, avatar, screenshot, onDone) { const vrmMeta = convertMetaToVRM0(vrm.meta); const humanoid = convertHumanoidToVRM0(vrm.humanoid); @@ -137,9 +137,11 @@ export default class VRMExporterv0 { .map((material) => material); const uniqueMaterialNames = uniqueMaterials.map((material) => material.name); - const icon = vrmMeta.texture - ? { name: "icon", imageBitmap: vrmMeta.texture.image } + + const icon = screenshot + ? { name: "icon", imageBitmap: screenshot.image } : null; // TODO: ない場合もある + const mainImages = uniqueMaterials .filter((material) => material.map) .map((material) => { @@ -154,10 +156,7 @@ export default class VRMExporterv0 { throw new Error(material.userData.shadeTexture + " map is null"); return { name: material.name + "_shade", imageBitmap: material.userData.shadeTexture.image }; }); // TODO: 画像がないMaterialもある\ - - - const images = mainImages.concat(shadeImages); const outputImages = toOutputImages(images, icon); @@ -190,6 +189,7 @@ export default class VRMExporterv0 { const meshes = avatar.children.filter((child) => child.type === VRMObjectType.Group || child.type === VRMObjectType.SkinnedMesh); const meshDatas = []; + meshes.forEach((object) => { const mesh = (object.type === VRMObjectType.Group ? object.children[0] @@ -417,6 +417,8 @@ export default class VRMExporterv0 { //const outputVrmMeta = ToOutputVRMMeta(vrmMeta, icon, outputImages); const outputVrmMeta = vrmMeta; + outputVrmMeta.texture = icon ? outputImages.length - 1 : undefined; + //const outputSecondaryAnimation = toOutputSecondaryAnimation(springBone, nodeNames); const bufferViews = []; bufferViews.push(...images.map((image) => ({ diff --git a/src/library/animationManager.js b/src/library/animationManager.js index 31201aaf..f563f277 100644 --- a/src/library/animationManager.js +++ b/src/library/animationManager.js @@ -48,6 +48,15 @@ class AnimationControl { this.actions[curIdx].play(); } + reset() { + this.mixer.setTime(0); + this.to.paused = true; + } + + resume() { + this.to.paused = false; + } + dispose(){ this.animationManager.disposeAnimation(this); //console.log("todo dispose animation control") @@ -93,6 +102,18 @@ export class AnimationManager{ } + enableScreenshot() { + this.animationControls.forEach(control => { + control.reset() + }); + } + + disableScreenshot() { + this.animationControls.forEach(control => { + control.resume() + }); + } + offsetHips(){ this.animations.forEach(anim => { for (let i =0; i < anim.tracks.length; i++){ @@ -201,4 +222,4 @@ export class AnimationManager{ else this.weightOut = 0; } } -} +} \ No newline at end of file diff --git a/src/library/blinkManager.js b/src/library/blinkManager.js index 1650e8ae..ae744e6d 100644 --- a/src/library/blinkManager.js +++ b/src/library/blinkManager.js @@ -1,6 +1,7 @@ import { VRMExpressionPresetName } from "@pixiv/three-vrm"; import { Clock } from "three"; +const SCREENSHOT_EYES_OPEN_THRESHOLD = 2; export class BlinkManager { constructor(closeTime = 0.5, openTime = 0.5, continuity = 1, randomness = 5) { @@ -17,6 +18,8 @@ export class BlinkManager { this._eyeOpen = 1 this._blinkCounter = 0; + this.isTakingScreenShot = false; + this.update() } @@ -24,8 +27,21 @@ export class BlinkManager { this.vrmBlinkers.push(vrm) } + enableScreenshot() { + this.isTakingScreenShot = true; + this._eyeOpen = SCREENSHOT_EYES_OPEN_THRESHOLD; + this._updateBlinkers(); + } + + disableScreenshot() { + this.isTakingScreenShot = false; + } + update(){ setInterval(() => { + if (this.isTakingScreenShot) { + return; + } const deltaTime = this.clock.getDelta() switch (this.mode){ @@ -33,7 +49,7 @@ export class BlinkManager { if ( this._eyeOpen > 0) this._eyeOpen -= deltaTime / this.closeTime; else{ - this._eyeOpen =0 + this._eyeOpen = 0 this.mode = 'open' } this._updateBlinkers(); @@ -42,7 +58,7 @@ export class BlinkManager { if ( this._eyeOpen < 1) this._eyeOpen += deltaTime / this.openTime; else{ - this._eyeOpen =1 + this._eyeOpen = 1 this.mode = 'ready' } this._updateBlinkers(); diff --git a/src/library/download-utils.js b/src/library/download-utils.js index 07e35401..f23f0284 100644 --- a/src/library/download-utils.js +++ b/src/library/download-utils.js @@ -69,16 +69,16 @@ function getOptimizedGLB(avatarToDownload, atlasSize, isVrm0 = false){ } export async function getGLBBlobData(avatarToDownload, atlasSize = 4096, optimized = true){ - const model = await optimized ? - getOptimizedGLB(avatarToDownload, atlasSize) : - getUnopotimizedGLB(avatarToDownload) + const model = await (optimized ? + getOptimizedGLB(avatarToDownload, atlasSize) : + getUnopotimizedGLB(avatarToDownload)) const glb = await parseGLB(model); return new Blob([glb], { type: 'model/gltf-binary' }); } -export async function getVRMBlobData(avatarToDownload, avatar, atlasSize = 4096, isVrm0 = false){ +export async function getVRMBlobData(avatarToDownload, avatar, screenshot = null, atlasSize = 4096, isVrm0 = false){ const model = await getOptimizedGLB(avatarToDownload, atlasSize, isVrm0) - const vrm = await parseVRM(model, avatar, isVrm0); + const vrm = await parseVRM(model, avatar, screenshot, isVrm0); // save it as glb now return new Blob([vrm], { type: 'model/gltf-binary' }); } @@ -94,17 +94,17 @@ async function getGLBData(avatarToDownload, atlasSize = 4096, optimized = true) return parseGLB(model); } } -async function getVRMData(avatarToDownload, avatar, atlasSize = 4096, isVrm0 = false){ +async function getVRMData(avatarToDownload, avatar, screenshot = null, atlasSize = 4096, isVrm0 = false){ const vrmModel = await getOptimizedGLB(avatarToDownload, atlasSize, isVrm0); - return parseVRM(vrmModel,avatar, isVrm0) + return parseVRM(vrmModel,avatar,screenshot, isVrm0) } -export async function downloadVRM(avatarToDownload, avatar, fileName = "", atlasSize = 4096, isVrm0 = false){ +export async function downloadVRM(avatarToDownload, avatar, fileName = "", screenshot = null, atlasSize = 4096, isVrm0 = false){ const downloadFileName = `${ fileName && fileName !== "" ? fileName : "AvatarCreatorModel" }` - getVRMData(avatarToDownload, avatar, atlasSize, isVrm0).then((vrm)=>{ + getVRMData(avatarToDownload, avatar, screenshot, atlasSize, isVrm0).then((vrm)=>{ saveArrayBuffer(vrm, `${downloadFileName}.vrm`) }) } @@ -150,7 +150,7 @@ function parseGLB (glbModel){ }) } -function parseVRM (glbModel, avatar, isVrm0 = false){ +function parseVRM (glbModel, avatar, screenshot = null, isVrm0 = false){ return new Promise((resolve) => { const exporter = isVrm0 ? new VRMExporterv0() : new VRMExporter() const vrmData = { @@ -174,7 +174,7 @@ function parseVRM (glbModel, avatar, isVrm0 = false){ skinnedMesh.skeleton.calculateInverses(); skinnedMesh.skeleton.computeBoneTexture(); skinnedMesh.skeleton.update(); - exporter.parse(vrmData, glbModel, (vrm) => { + exporter.parse(vrmData, glbModel,screenshot, (vrm) => { resolve(vrm) }) }) diff --git a/src/library/mint-utils.js b/src/library/mint-utils.js new file mode 100644 index 00000000..cc70884d --- /dev/null +++ b/src/library/mint-utils.js @@ -0,0 +1,242 @@ +import { BigNumber, ethers } from "ethers" +import { getVRMBlobData } from "./download-utils" +import { CharacterContract, EternalProxyContract, webaverseGenesisAddress } from "../components/Contract" +import axios from "axios" + +const pinataApiKey = import.meta.env.VITE_PINATA_API_KEY +const pinataSecretApiKey = import.meta.env.VITE_PINATA_API_SECRET + +//const mintCost = 0.01 +const chainId = "0x89"; +let tokenPrice; + + +async function getTokenPrice(){ + if (tokenPrice != null) + return tokenPrice + const defaultProvider = new ethers.providers.StaticJsonRpcProvider('https://polygon-rpc.com/') + const contract = new ethers.Contract(CharacterContract.address, CharacterContract.abi, defaultProvider) + const tp = await contract.tokenPrice() + tokenPrice = BigNumber.from(tp).mul(1); + return tokenPrice +} + +// ready to test +async function connectWallet(){ + if (window.ethereum) { + try { + const chain = await window.ethereum.request({ method: 'eth_chainId' }) + if (parseInt(chain, 16) == parseInt(chainId, 16)) { + const addressArray = await window.ethereum.request({ + method: 'eth_requestAccounts', + }) + return addressArray.length > 0 ? addressArray[0] : "" + } else { + try { + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainId }], + }) + const addressArray = await window.ethereum.request({ + method: 'eth_requestAccounts', + }) + return addressArray.length > 0 ? addressArray[0] : "" + } catch (err) { + console.log("polygon not find:", err) + // Add Polygon chain to the metamask. + try { + await window.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: '0x89', + chainName: 'Polygon Mainnet', + rpcUrls: ['https://polygon-rpc.com'], + nativeCurrency: { + name: "Matic", + symbol: "MATIC", + decimals: 18 + }, + blockExplorerUrls: ['https://polygonscan.com/'] }, + ] + }); + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainId }], + }) + const addressArray = await window.ethereum.request({ + method: 'eth_requestAccounts', + }) + return addressArray.length > 0 ? addressArray[0] : "" + } catch (error) { + console.log("Adding polygon chain failed", error); + } + } + } + } catch (err) { + return ""; + } + } else { + return ""; + } +} + +// ready to test +async function saveFileToPinata(fileData, fileName) { + if (!fileData) return console.warn("Error saving to pinata: No file data") + const url = `https://api.pinata.cloud/pinning/pinFileToIPFS` + let data = new FormData() + + data.append("file", fileData, fileName) + let resultOfUpload = await axios.post(url, data, { + maxContentLength: "Infinity", //this is needed to prevent axios from erroring out with large files + maxBodyLength: "Infinity", //this is needed to prevent axios from erroring out with large files + headers: { + "Content-Type": `multipart/form-data; boundary=${data._boundary}`, + pinata_api_key: pinataApiKey, + pinata_secret_api_key: pinataSecretApiKey, + }, + }) + return resultOfUpload.data +} + +const getAvatarTraits = (avatar) => { + let metadataTraits = [] + Object.keys(avatar).map((trait) => { + if (Object.keys(avatar[trait]).length !== 0) { + metadataTraits.push({ + trait_type: trait, + value: avatar[trait].name, + }) + } + }) + return metadataTraits +} + +export async function mintAsset(avatar, screenshot, model, name, needCheckOT){ + if (!avatar) + throw new Error("No avatar was provided") + if (!screenshot) + throw new Error("No screenshot was provided") + if (!model) + throw new Error("No model was provided") + + const walletAddress = await connectWallet(); + if (walletAddress == "") + return ("Please Connect Wallet") + + const pass = !needCheckOT || await checkOT(walletAddress); + if (pass){ + console.log("minting") + // set image + let imageName = "AvatarImage_" + Date.now() + ".png"; + let imageHash = await (async() => { + for (let i = 0; i < 10; i++) { // hack: give it a few tries, sometimes uploading to pinata fail for some reason + try { + const img_hash = await saveFileToPinata( + screenshot, + imageName + ).catch((reason) => { + console.error(i, "---", reason) + }) + return img_hash + } catch(err) { + console.warn(err); + return err; + } + } + return 'Failed to upload screenshot'; + //throw new Error('failed to upload screenshot'); + })() + const glb = await getVRMBlobData(model,avatar,4096,true) + let glbHash; + if (glb) { + let glbName = "AvatarGlb_" + Date.now() + ".glb"; + glbHash = await (async() => { + for (let i = 0; i < 10; i++) { // hack: give it a few tries, sometimes uploading to pinata fail for some reason + try { + const glb_hash = await saveFileToPinata( + glb, + glbName + ).catch((reason) => { + console.error(i, "---", reason) + return "Couldn't save glb to pinata" + //setMintStatus("Couldn't save glb to pinata") + }) + return glb_hash + } catch(err) { + console.warn(err); + return "Couldn't save glb to pinata" + } + } + return 'Failed to upload glb' + //throw new Error('failed to upload glb'); + })(); + } else { + return 'Unable to get glb' + } + const metadata = { + name: name || "Avatars", + description: "Character Studio Avatars.", + image: `ipfs://${imageHash.IpfsHash}`, + animation_url: `ipfs://${glbHash.IpfsHash}`, + attributes: getAvatarTraits(avatar) + } + const str = JSON.stringify(metadata) + const metaDataHash = await saveFileToPinata( + new Blob([str]), + "AvatarMetadata_" + Date.now() + ".json", + ) + const metadataIpfs = `ipfs://${metaDataHash.IpfsHash}` + + let price = await getTokenPrice() + + const signer = new ethers.providers.Web3Provider( + window.ethereum, + ).getSigner() + const contract = new ethers.Contract(CharacterContract.address, CharacterContract.abi, signer) + try { + const options = { + value: price, + from: walletAddress + } + const tx = await contract.mintToken(1, metadataIpfs, options) + let res = await tx.wait() + if (res.transactionHash) { + console.log("Mint success!") + return "Mint success!"; + } + } catch (err) { + //console.log("Public Mint failed! Please check your wallet.") + return "Public Mint failed." + } + + } +} + +const checkOT = async (address) => { + if(address) { + const address = '0x6e58309CD851A5B124E3A56768a42d12f3B6D104' + const ethersigner = ethers.getDefaultProvider("mainnet", { + alchemy: import.meta.env.VITE_ALCHEMY_API_KEY, + }) + const contract = new ethers.Contract(EternalProxyContract.address, EternalProxyContract.abi, ethersigner); + const webaBalance = await contract.beneficiaryBalanceOf(address, webaverseGenesisAddress, 1); + if(parseInt(webaBalance) > 0) return true; + else { + console.log("Currently in alpha. You need a genesis pass to mint. \n Will be public soon!") + return false; + } + } else { + console.log("Please connect your wallet") + return false; + } +} + +// const showTrait = (trait) => { +// if (trait.name in avatar) { +// if ("traitInfo" in avatar[trait.name]) { +// return avatar[trait.name].name +// } else return "Default " + trait.name +// } else return "No set" +// } \ No newline at end of file diff --git a/src/library/screenshotManager.js b/src/library/screenshotManager.js new file mode 100644 index 00000000..7989c309 --- /dev/null +++ b/src/library/screenshotManager.js @@ -0,0 +1,116 @@ +import * as THREE from "three" +import { Buffer } from "buffer"; + +const screenshotSize = 4096; + +const localVector = new THREE.Vector3(); + +const textureLoader = new THREE.TextureLoader(); +const backgroundTexture = textureLoader.load(`/assets/backgrounds/main-background2.jpg`); +backgroundTexture.wrapS = backgroundTexture.wrapT = THREE.RepeatWrapping; + +export class ScreenshotManager { + constructor() { + this.renderer = new THREE.WebGLRenderer({ + preserveDrawingBuffer: true, + antialias: true + }); + this.renderer.outputEncoding = THREE.sRGBEncoding; + this.renderer.setSize(screenshotSize, screenshotSize); + + this.camera = new THREE.PerspectiveCamera( 30, 1, 0.1, 1000 ); + } + + setCamera(headPosition, playerCameraDistance) { + this.camera.position.copy(headPosition); + + localVector.set(0, 0, -1); + this.cameraDir = localVector.applyQuaternion(this.camera.quaternion); + this.cameraDir.normalize(); + this.camera.position.x -= this.cameraDir.x * playerCameraDistance; + this.camera.position.z -= this.cameraDir.z * playerCameraDistance; + + } + + saveAsImage(imageName) { + let imgData; + try { + this.scene.background = backgroundTexture; + this.renderer.render(this.scene, this.camera); + const strDownloadMime = "image/octet-stream"; + const strMime = "image/png"; + imgData = this.renderer.domElement.toDataURL(strMime); + + const base64Data = Buffer.from( + imgData.replace(/^data:image\/\w+;base64,/, ""), + "base64" + ); + const blob = new Blob([base64Data], { type: "image/jpeg" }); + + this.saveFile(imgData.replace(strMime, strDownloadMime), imageName); + this.scene.background = null; + return blob; + } catch (e) { + console.log(e); + return false; + } + + } + + _createImage(width, height){ + this.renderer.setSize(width, height); + try { + this.scene.background = backgroundTexture; + this.renderer.render(this.scene, this.camera); + const strMime = "image/png"; + let imgData = this.renderer.domElement.toDataURL(strMime); + this.scene.background = null; + return imgData + } catch (e) { + console.log(e); + return null; + } + } + saveScreenshot(imageName,width, height){ + const imgData = this._createImage(width, height) + const strDownloadMime = "image/octet-stream"; + const strMime = "image/png"; + this.saveFile(imgData.replace(strMime, strDownloadMime), imageName); + } + + getScreenshotImage(width, height){ + const imgData = this._createImage(width, height); + const img = new Image(); + img.src = imgData; + return img; + } + getScreenshotTexture(width, height){ + const img = this.getScreenshotImage(width,height) + const texture = new THREE.Texture(img); + texture.needsUpdate = true; + return texture; + } + getScreenshotBlob(width, height){ + const imgData = this._createImage(width, height) + const base64Data = Buffer.from( + imgData.replace(/^data:image\/\w+;base64,/, ""), + "base64" + ); + const blob = new Blob([base64Data], { type: "image/jpeg" }); + return blob; + } + saveFile (strData, filename) { + const link = document.createElement('a'); + if (typeof link.download === 'string') { + document.body.appendChild(link); //Firefox requires the link to be in the body + link.download = filename; + link.href = strData; + link.click(); + document.body.removeChild(link); //remove the link when done + } else { + const win = window.open(strData, "_blank"); + win.document.write("" + filename + ""); + } + } + +} \ No newline at end of file diff --git a/src/pages/Mint.jsx b/src/pages/Mint.jsx index e8abe2fb..63814b0d 100644 --- a/src/pages/Mint.jsx +++ b/src/pages/Mint.jsx @@ -1,20 +1,21 @@ import React from "react" -import styles from "./Mint.module.css" +import styles from "./Mint.module.scss" import { ViewMode, ViewContext } from "../context/ViewContext" - -import Mint from "../components/Mint" -import ResizableDiv from "../components/Resizable" +import { SceneContext } from "../context/SceneContext" import CustomButton from "../components/custom-button" - import { SoundContext } from "../context/SoundContext" import { AudioContext } from "../context/AudioContext" +import { mintAsset } from "../library/mint-utils" -function MintComponent() { +function MintComponent({getFaceScreenshot}) { + const { templateInfo, model, avatar } = React.useContext(SceneContext) const { setViewMode } = React.useContext(ViewContext) - const [screenshotPosition, setScreenshotPosition] = React.useState({x:250,y:25,width:256,height:256}); const { playSound } = React.useContext(SoundContext) const { isMute } = React.useContext(AudioContext) + const [status, setStatus] = React.useState("") + const [minting, setMinting]= React.useState(false) + const back = () => { setViewMode(ViewMode.SAVE) !isMute && playSound('backNextButton'); @@ -25,19 +26,70 @@ function MintComponent() { !isMute && playSound('backNextButton'); } + function MenuTitle() { + return ( +
+
+
Mint
+
+ ) + } + async function Mint(){ + !isMute && playSound('backNextButton'); + setMinting(true) + setStatus("Please check your wallet") + const fullBioStr = localStorage.getItem(`${templateInfo.id}_fulBio`) + const fullBio = JSON.parse(fullBioStr) + const screenshot = getFaceScreenshot(256,256,true); + const result = await mintAsset(avatar,screenshot,model, fullBio.name) + setStatus(result) + setMinting(false) + console.log(result); + } + return (
Mint Your Character
- + + {/* */} + + {/* */} +
-
-
-
+ +
+ + +
+ + + {/* Genesis pass holders only */} + (Coming Soon!) -
+ {status} +
-
+ +
div { + padding: 16px !important; + } + + .genesisText { + opacity: 0.4; + margin-top: 5px; + .required:after { + content: "*"; + color: red; + } + } + + .divider { + width: 80%; + height: 1px; + margin: 8px 0; + opacity: 0.2; + background: #e0e6e5; + } + } + } + + .bottomContainer { + z-index: 0; + display: flex; + padding: 20px 32px; + justify-content: space-between; + + button { + min-width: 120px; + } + } +} +.mintInfo { + height: 15px; + padding-bottom: 25px; + opacity: 0.5; +} + +.topLine { + background: rgb(0, 149, 100); + background: -moz-linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + + background: -webkit-linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + + background: linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + + top: 0; + left: 0; + width: 100%; + height: 1px; + position: absolute; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#009564",endColorstr="#009564",GradientType=1); +} + +.bottomLine { + background: rgb(0, 149, 100); + background: -moz-linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + background: -webkit-linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + background: linear-gradient( + 90deg, + rgba(0, 149, 100, 0) 0%, + rgba(8, 234, 160, 1) 50%, + rgba(0, 149, 100, 0) 100% + ); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#009564",endColorstr="#009564",GradientType=1); + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 1px; +} diff --git a/src/pages/Save.jsx b/src/pages/Save.jsx index dbd826a5..c3c9bc35 100644 --- a/src/pages/Save.jsx +++ b/src/pages/Save.jsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react" + import React, { useContext } from "react" import styles from "./Save.module.css" import { ExportMenu } from "../components/ExportMenu" @@ -8,26 +8,29 @@ import { LanguageContext } from "../context/LanguageContext" import { SoundContext } from "../context/SoundContext" import { AudioContext } from "../context/AudioContext" -function Save() { - const { setViewMode } = React.useContext(ViewContext); + +function Save({getFaceScreenshot}) { + + // Translate hook + const { t } = useContext(LanguageContext); const { playSound } = React.useContext(SoundContext) const { isMute } = React.useContext(AudioContext) + const { setViewMode } = React.useContext(ViewContext); + const back = () => { setViewMode(ViewMode.BIO) !isMute && playSound('backNextButton'); } const mint = () => { - setViewMode(ViewMode.CHAT) + setViewMode(ViewMode.MINT) + !isMute && playSound('backNextButton'); } const next = () => { setViewMode(ViewMode.CHAT) !isMute && playSound('backNextButton'); } - // Translate hook - const { t } = useContext(LanguageContext); - return (
{t("pageTitles.saveCharacter")}
@@ -39,16 +42,17 @@ function Save() { className={styles.buttonLeft} onClick={back} /> - - {/* - - */} + + + { + // setViewMode(ViewMode.MINT) + // } // Translate hook const { t } = useContext(LanguageContext);