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

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。