最近做了一个 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#
应用中的模型是我个人炼的,大模型底模或多或少会存在版权问题,所以单纯生成头像图片是完全免费的。
这里也放一些之前炼丹时生成的不错的头像,大家喜欢可以拿去用: