moayuisuda

moayuisuda

try to do something [email protected]

sd-webui+智能合约构建头像生成dapp

下载 (1)
最近做了一个 dapp 小项目用来巩固学习一下智能合约,也尝试用 ai 来增强 nft 的定制与趣味性。这篇文章主要记录一下其中的一些关键步骤。

Brain Storm#

我习惯先脑暴一下,想象出最终的效果,然后反推实现。

  1. 用 ai 来生成头像,单纯生成头像有一点无聊了,生成的头像可以与现实世界的状态有联系,我想要一种区块来锚定住 ai 的黑盒产出和现实世界状态的感觉。
  2. 加入抽卡元素,而对于 ai 绘画来说,这个 “卡 “设定为 “绘画关键词”。
  3. 生成的头像可以铸造为 nft,单纯铸造也有点无聊,它不应该只是一张上链的图片,可以有更多 “正版” 的感觉。我现在愿意给正版游戏、画作花钱一是为了支持作者,二很大程度是因为后续的配套服务。除了 nft 天然的交易特性,铸造后的用户应该拥有更多附加的延伸功能。

简单确定一下第一个版本用户动线:

  1. 进入后,点击生成按钮(可以有额外配置),生成头像图片。
  2. 生成后,可以选择其中一张铸造为 nft。
  3. 如果要铸造,点击连接钱包,点击铸造。
  4. 铸造完成后,会记录铸造信息,作为提供后续服务的基础(如可以基于铸造过的图片 seed 上再生成)。同时会奖励一定的点数,并抽取一个绘画关键词。
    image

效果预览#

gpu 服务器非常贵,,为了防止恶意 ddos,在线试玩暂时只开放给了几个朋友,这里截图看一下流程:

初始状态,用户没有链接钱包,也仅有 “flower” 关键词可用:
image

点击 “make a wish” 即可生成头像
每张头像的结果都与生成的时间,地点等有一定关联。比如你在日本,白天生成,那么头像中的人物会在白天,穿着和服:
image

获取图片后,如果想要进行铸造则需要连接钱包:
image

连接钱包并铸造成功,会赠送一个抽取的绘画关键词:
image

回到首页,就会发现你可以基于已铸造的图片进行头像图片生成,也增加了额外的可用关键词:
image

我们试试在之前头像的基础上,用上新的关键词进行生成:
image

新关键词 “Mononoke Hime” 出自《幽灵公主》,生成的头像也会有幽灵公主的风格:
image

预览看完了,接下来说说应用中各个部分是如何实现的

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,你应该就可以看到对应的接口文档:

image

然后就可以像调用普通接口一样调用 sd-webui 的接口了。比如最常用的 text2img 功能:
image
接口返回的图片资源是 base64 字符串,除了方便前端处理外,还可以利用 sd 模型黑盒噪声的随机性,将 base64 字符串或者直接将图片 seed 做 hash,作为应用其他功能如抽奖的伪随机种子。
如果部署后需要进行跨域请求,在运行 "webui.sh" 时加上--cors-allow-origins参数。

※ 如果安装过程问题太多,可以试试直接用这个 docker 镜像:https://hub.docker.com/r/kestr3l/stable-diffusion-webui

合约编写#

合约需要的功能:

  1. 基础的 nft 功能(铸造、转移、可关联 tokenURI)。
  2. 记录某用户拥有的所有 nft tokenId。
  3. 积分功能(后续服务会用到)。

这次选择以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。
image

合约中也定义了一个 “_points” 的 map,用来存储点数,这个点数会在之后用作其他服务的代币(这里为了简单没有用额外的 ERC20 合约去实现代币,如果你想要它是更加 “标准” 的代币,可以用 ERC20 单独实现,或者将本合约换成 ERC1155 以实现多种代币管理)。

构建前端界面#

我这次使用 next.js+tailwind+react-moralis+ethers.js 来构建、部署前端的服务。
这里就不多讲了,代码放在 https://github.com/moayuisuda/HimeAvatar

值得注意的是 react-moralis 的useWeb3Contracthook 是支持分布调用的:
你可以直接调用合约方法:

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#

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

00214-948687487
下载 (2)
00098-3474647607
00204-2453144948
00011-1103441608
00228-3086404795
00004-3718831879
00289-146167765
00000-1103441605

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。