最近做了一個 dapp 小項目用來鞏固學習一下智能合約,也嘗試用 ai 來增強 nft 的定制與趣味性。這篇文章主要記錄一下其中的一些關鍵步驟。
Brain Storm#
我習慣先腦暴一下,想象出最終的效果,然後反推實現。
- 用 ai 來生成頭像,單純生成頭像有一點無聊了,生成的頭像可以與現實世界的狀態有聯繫,我想要一種區塊來錨定住 ai 的黑盒產出和現實世界狀態的感覺。
- 加入抽卡元素,而對於 ai 繪畫來說,這個 “卡 “設定為 “繪畫關鍵詞”。
- 生成的頭像可以鑄造為 nft,單純鑄造也有點無聊,它不應該只是一張上鏈的圖片,可以有更多 “正版” 的感覺。我現在願意給正版遊戲、畫作花錢一是為了支持作者,二很大程度是因為後續的配套服務。除了 nft 天然的交易特性,鑄造後的用戶應該擁有更多附加的延伸功能。
簡單確定一下第一個版本用戶動線:
- 進入後,點擊生成按鈕(可以有額外配置),生成頭像圖片。
- 生成後,可以選擇其中一張鑄造為 nft。
- 如果要鑄造,點擊連接錢包,點擊鑄造。
- 鑄造完成後,會記錄鑄造信息,作為提供後續服務的基礎(如可以基於鑄造過的圖片 seed 上再生成)。同時會獎勵一定的點數,並抽取一個繪畫關鍵詞。
效果預覽#
gpu 伺服器非常貴,為了防止惡意 ddos,線上試玩暫時只開放給了幾個朋友,這裡截圖看一下流程:
初始狀態,用戶沒有連接錢包,也僅有 “flower” 關鍵詞可用:
點擊 “make a wish” 即可生成頭像
每張頭像的結果都與生成的時間,地點等有一定關聯。比如你在日本,白天生成,那麼頭像中的人物會在白天,穿著和服:
獲取圖片後,如果想要進行鑄造則需要連接錢包:
連接錢包並鑄造成功,會贈送一個抽取的繪畫關鍵詞:
回到首頁,就會發現你可以基於已鑄造的圖片進行頭像圖片生成,也增加了額外的可用關鍵詞:
我們試試在之前頭像的基礎上,用上新的關鍵詞進行生成:
新關鍵詞 “Mononoke Hime” 出自《幽靈公主》,生成的頭像也會有幽靈公主的風格:
預覽看完了,接下來說說應用中各個部分是如何實現的
AI 頭像生成服務#
當前最流行的開源 ai 畫作生成項目 stable-diffusion-webui(後面簡稱 sd-webui),在部署後除了可以直接啟動一個本地 web ui 服務,也可以以 api 模式運行,暴露出接口供其他服務調用。只需要在啟動時加上--api
參數就可以。
克隆倉庫並安裝,在 sd-webui 的倉庫下運行命令
./webui.sh --api
或者
./webui.sh --nowebui
區別是--api
除了提供 api 接口服務外,依然會啟動原來的本地 web ui 服務,而--nowebui
則單純提供接口服務。
啟動成功後,打開此網頁:
http://127.0.0.1:7860/docs
,你應該就可以看到對應的接口文檔:
然後就可以像調用普通接口一樣調用 sd-webui 的接口了。比如最常用的 text2img 功能:
接口返回的圖片資源是 base64 字符串,除了方便前端處理外,還可以利用 sd 模型黑盒噪聲的隨機性,將 base64 字符串或者直接將圖片 seed 做 hash,作為應用其他功能如抽獎的偽隨機種子。
如果部署後需要進行跨域請求,在運行 "webui.sh" 時加上--cors-allow-origins
參數。
※ 如果安裝過程問題太多,可以試試直接用這個 docker 鏡像:https://hub.docker.com/r/kestr3l/stable-diffusion-webui
合約編寫#
合約需要的功能:
- 基礎的 nft 功能(鑄造、轉移、可關聯 tokenURI)。
- 記錄某用戶擁有的所有 nft tokenId。
- 積分功能(後續服務會用到)。
這次選擇以ERC721Enumerable
為基礎合約來編寫智能合約。合約代碼如下
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";
error Insufficient_Mint_Value();
contract HimeNft is ERC721Enumerable, Ownable {
uint256 private constant MINT_PRICE = 0.1 * 1e18;
uint256 private _tokenId = 0;
mapping(uint256 => string) private _tokenURIs;
mapping(address => uint256) private _points;
constructor() ERC721("HimeNft", "HN") {}
function mintNft(
string calldata tokenURI
) public payable returns (uint256) {
if (msg.value < MINT_PRICE) revert Insufficient_Mint_Value();
_safeMint(msg.sender, _tokenId);
_setTokenURI(_tokenId, tokenURI);
_tokenId += 1;
_points[msg.sender] += 10;
return _tokenId;
}
function withDraw() public onlyOwner {
(bool callSuccess, ) = payable(msg.sender).call{
value: address(this).balance
}("");
require(callSuccess, "call failed");
}
function _setTokenURI(
uint256 tokenId,
string memory _tokenURI
) internal virtual {
require(
_exists(tokenId),
"ERC721URIStorage: URI set of nonexistent token"
);
_tokenURIs[tokenId] = _tokenURI;
}
function tokenURI(
uint256 tokenId
) public view virtual override returns (string memory) {
_requireMinted(tokenId);
return _tokenURIs[tokenId];
}
function getTokenCounter() public view returns (uint256) {
return _tokenId;
}
function getPoints(address owner) public view returns (uint256) {
return _points[owner];
}
function getOwnedTokens(
address owner
) public view returns (uint256[] memory) {
uint256 tokenCount = balanceOf(owner);
uint256[] memory result = new uint256[](tokenCount);
for (uint256 i = 0; i < tokenCount; i++) {
result[i] = tokenOfOwnerByIndex(owner, i);
}
return result;
}
}
※ 這裡只是簡單實現,為了之後的功能升級,你最好使用代理合約來代理應用與合約間的交互
順便附上測試代碼,方便本地測試:
const { assert, expect } = require("chai");
const { deployments, ethers, getNamedAccounts } = require("hardhat");
describe("HimeNft", async () => {
const MINT_VALUE = ethers.utils.parseEther("0.1");
const INSUFFICIENT_MINT_VALUE = MINT_VALUE.sub(1);
const TEST_TOKENURI = "TEST_TOKENUR";
let deployer, nft;
beforeEach(async () => {
await deployments.fixture();
deployer = (await getNamedAccounts()).deployer;
nft = await ethers.getContract("HimeNft", deployer);
});
it("initial token count is 0", async () => {
const counter = await nft.getTokenCounter();
assert.equal(counter, 0);
});
it("revert on mint value is insufficient", async () => {
expect(
nft.mintNft(TEST_TOKENURI, {
value: INSUFFICIENT_MINT_VALUE,
})
).to.be.revertedWith("Insufficient_Mint_Value");
});
it("add correct points after mint", async () => {
await nft.mintNft(TEST_TOKENURI, {
value: MINT_VALUE,
});
const points = (await nft.getPoints(deployer)).toString();
assert.equal(points, "10");
});
it("get correct tokenURI and token list", async () => {
const TEST_TOKENURI0 = "TEST_TOKENURI0";
const TEST_TOKENURI1 = "TEST_TOKENURI1";
await Promise.all([
nft.mintNft(TEST_TOKENURI0, {
value: MINT_VALUE,
}),
nft.mintNft(TEST_TOKENURI1, {
value: MINT_VALUE,
}),
]);
const list = (await nft.getOwnedTokens(deployer)).toString();
const tokenURI0 = await nft.tokenURI(0);
assert.equal(tokenURI0, TEST_TOKENURI0);
assert.equal(list, "0,1");
});
});
但你可能會發現,這裡好像並沒有 “抽取一個繪畫關鍵詞” 這個邏輯。
記得上文提到的 “直接將圖片 seed 做 hash,作為應用其他功能如抽獎的偽隨機種子” 嗎,為了節省存儲空間,我將圖片的 seed 存入 nft 的 metaData 中,並將 seed 映射為一個繪畫關鍵詞。這樣 mint 的同時也可以完成 “抽取繪畫關鍵詞”,同時也節約 gas。
比如最簡單的,我有一個這樣的 map:
{
"royal": 0,
"cute": 1,
}
我將 seed 做 mod 取餘處理,如 seed 為 334451,除數我們設置為 10,取餘 mod (334451,10),結果為 1,那對應的抽卡結果就是 “cute” 關鍵詞。當然這個映射算法還可以更加複雜,甚至配置各項的概率。
為了足夠的透明性,這個 map 方法應該上傳至區塊鏈並 verify。
合約中也定義了一個 “_points” 的 map,用來存儲點數,這個點數會在之後用作其他服務的代幣(這裡為了簡單沒有用額外的 ERC20 合約去實現代幣,如果你想要它是更加 “標準” 的代幣,可以用 ERC20 單獨實現,或者將本合約換成 ERC1155 以實現多種代幣管理)。
構建前端界面#
我這次使用 next.js+tailwind+react-moralis+ethers.js 來構建、部署前端的服務。
這裡就不多講了,代碼放在 https://github.com/moayuisuda/HimeAvatar
值得注意的是 react-moralis 的useWeb3Contract
hook 是支持分布調用的:
你可以直接調用合約方法:
const { runContractFunction } = useWeb3Contract({
abi,
contractAddress: address[chainId as keyof typeof address],
functionName: "mintNft",
params: { tokenURI: "TOKENURL" },
});
runContractFunction()
也可以先定義合約的 abi、地址等基礎信息,再調用具體的方法:
const { runContractFunction } = useWeb3Contract({
abi,
contractAddress: address[chainId as keyof typeof address],
});
runContractFunction({
params: {
functionName: "mintNft",
params: { tokenURI: "TOKENURL" },
},
})
至此各個部分的搭建就都完成了,你可以將這幾個服務打包為 docker,方便在伺服器一鍵部署。
Todo#
- 用 openai 的 sdk 構建可連貫世界觀、對話(個人建議用 python 版本的 sdk,nodejs 版個人覺得用起來很別扭),讓單純的構建頭像變為構建 “人物”。
- 支持創作者自行上傳種子圖片並生成 lora 模型,生產圖片並進行創作者分成。
- 構建 points 代幣的額外服務商店。
Gift#
應用中的模型是我個人煉的,大模型底模或多或少會存在版權問題,所以單純生成頭像圖片是完全免費的。
這裡也放一些之前煉丹時生成的不錯的頭像,大家喜歡可以拿去用: