IIIFとNFTの統合

IIIF(International Image Interoperability Framework)は、デジタル文化財の画像と関連メタデータを標準的な方法で公開・共有するための国際規格です。世界中の図書館・博物館・美術館がIIIFを採用しており、高解像度画像のズーム表示、異なる機関の画像の比較、注釈の付与などが可能です。

本章では、IIIFのManifest URIをNFTのtokenURIとしてオンチェーンに記録し、Mirador等のIIIFビューアで閲覧できるデジタルアーカイブNFTを構築します。これにより、ブロックチェーンの不変性とIIIFの相互運用性を組み合わせた、デジタル文化財管理の仕組みを実現します。

IIIFの基本概念

コンポーネント役割
Image API画像の切り出し・サイズ変更・回転等を行うAPI
Presentation API画像の構造やメタデータを記述するManifest
CanvasManifestの基本単位。1つの画像面を表す
Manifest複数のCanvasをまとめた単位。1つの資料を表す

IIIF対応NFTコントラクト

通常のNFTでは tokenURI にIPFS上のJSONメタデータを指定しますが、ここではIIIF Manifest URIを直接保持するフィールドを追加します。tokenURIとは別にIIIF固有の情報を管理することで、IIIFビューアとNFTマーケットプレイスの両方に対応できます。

// contracts/IIIFCulturalNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract IIIFCulturalNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    /// @notice IIIF関連の情報
    struct IIIFInfo {
        string manifestURI;  // IIIF Manifest URI
        string canvasId;     // 対象の Canvas ID
        string label;        // 資料名(ラベル)
    }

    // tokenId => IIIFInfo
    mapping(uint256 => IIIFInfo) private _iiifInfo;

    // イベント
    event NFTMintedWithIIIF(
        uint256 indexed tokenId,
        address indexed to,
        string manifestURI,
        string canvasId
    );

    event IIIFInfoUpdated(
        uint256 indexed tokenId,
        string manifestURI,
        string canvasId
    );

    constructor()
        ERC721("IIIF Cultural Heritage NFT", "IIIF-CHN")
        Ownable(msg.sender)
    {}

    /// @notice IIIF情報付きでNFTをミントする
    /// @param to ミント先アドレス
    /// @param nftMetadataURI NFT標準メタデータのURI(ipfs://...)
    /// @param manifestURI IIIF Manifest URI
    /// @param canvasId 対象の Canvas ID
    /// @param label 資料名
    function mintWithIIIF(
        address to,
        string memory nftMetadataURI,
        string memory manifestURI,
        string memory canvasId,
        string memory label
    ) public onlyOwner returns (uint256) {
        uint256 tokenId = _nextTokenId++;

        _safeMint(to, tokenId);
        _setTokenURI(tokenId, nftMetadataURI);

        _iiifInfo[tokenId] = IIIFInfo({
            manifestURI: manifestURI,
            canvasId: canvasId,
            label: label
        });

        emit NFTMintedWithIIIF(tokenId, to, manifestURI, canvasId);

        return tokenId;
    }

    /// @notice IIIF情報を更新する(オーナーのみ)
    function updateIIIFInfo(
        uint256 tokenId,
        string memory manifestURI,
        string memory canvasId,
        string memory label
    ) public onlyOwner {
        require(_ownerOf(tokenId) != address(0), "Token does not exist");

        _iiifInfo[tokenId] = IIIFInfo({
            manifestURI: manifestURI,
            canvasId: canvasId,
            label: label
        });

        emit IIIFInfoUpdated(tokenId, manifestURI, canvasId);
    }

    /// @notice IIIF Manifest URIを取得する
    function iiifManifestOf(
        uint256 tokenId
    ) public view returns (string memory) {
        require(_ownerOf(tokenId) != address(0), "Token does not exist");
        return _iiifInfo[tokenId].manifestURI;
    }

    /// @notice IIIF Canvas IDを取得する
    function iiifCanvasOf(
        uint256 tokenId
    ) public view returns (string memory) {
        require(_ownerOf(tokenId) != address(0), "Token does not exist");
        return _iiifInfo[tokenId].canvasId;
    }

    /// @notice IIIF情報をすべて取得する
    function getIIIFInfo(
        uint256 tokenId
    ) public view returns (IIIFInfo memory) {
        require(_ownerOf(tokenId) != address(0), "Token does not exist");
        return _iiifInfo[tokenId];
    }

    // === 必須のオーバーライド ===

    function tokenURI(
        uint256 tokenId
    )
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

NFTメタデータにIIIF情報を含める

NFT標準のメタデータJSONに、IIIF関連の情報を追加します。external_url にIIIFビューアのURLを設定することで、OpenSea等のマーケットプレイスからIIIFビューアに遷移できます。

{
  "name": "洛中洛外図屏風(右隻)",
  "description": "国宝・洛中洛外図屏風の右隻デジタル複製。狩野永徳筆。室町時代末期の京都を描いた六曲一双の屏風。",
  "image": "ipfs://QmYourThumbnailHash",
  "external_url": "https://projectmirador.org/embed/?iiif-content=https://example.org/iiif/rakuchu/manifest.json",
  "attributes": [
    {
      "trait_type": "Institution",
      "value": "米沢市上杉博物館"
    },
    {
      "trait_type": "Creator",
      "value": "狩野永徳"
    },
    {
      "trait_type": "Period",
      "value": "室町時代末期"
    },
    {
      "trait_type": "IIIF Manifest",
      "value": "https://example.org/iiif/rakuchu/manifest.json"
    }
  ]
}

テスト

// test/IIIFCulturalNFT.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("IIIFCulturalNFT", function () {
  const MANIFEST_URI =
    "https://example.org/iiif/choju-giga/manifest.json";
  const CANVAS_ID =
    "https://example.org/iiif/choju-giga/canvas/p1";
  const LABEL = "鳥獣戯画 甲巻";

  async function deployFixture() {
    const [owner, user1] = await ethers.getSigners();
    const NFT = await ethers.getContractFactory("IIIFCulturalNFT");
    const nft = await NFT.deploy();
    return { nft, owner, user1 };
  }

  it("IIIF情報付きでNFTをミントできる", async function () {
    const { nft, owner } = await deployFixture();

    await nft.mintWithIIIF(
      owner.address,
      "ipfs://QmMetadata",
      MANIFEST_URI,
      CANVAS_ID,
      LABEL
    );

    // NFT標準メタデータ
    expect(await nft.tokenURI(0)).to.equal("ipfs://QmMetadata");

    // IIIF情報
    expect(await nft.iiifManifestOf(0)).to.equal(MANIFEST_URI);
    expect(await nft.iiifCanvasOf(0)).to.equal(CANVAS_ID);

    const info = await nft.getIIIFInfo(0);
    expect(info.label).to.equal(LABEL);
  });

  it("NFTMintedWithIIIFイベントが発行される", async function () {
    const { nft, owner } = await deployFixture();

    await expect(
      nft.mintWithIIIF(
        owner.address,
        "ipfs://QmMetadata",
        MANIFEST_URI,
        CANVAS_ID,
        LABEL
      )
    )
      .to.emit(nft, "NFTMintedWithIIIF")
      .withArgs(0, owner.address, MANIFEST_URI, CANVAS_ID);
  });

  it("IIIF情報を更新できる", async function () {
    const { nft, owner } = await deployFixture();

    await nft.mintWithIIIF(
      owner.address,
      "ipfs://QmMetadata",
      MANIFEST_URI,
      CANVAS_ID,
      LABEL
    );

    const newManifest =
      "https://example.org/iiif/v2/choju-giga/manifest.json";
    await nft.updateIIIFInfo(0, newManifest, CANVAS_ID, LABEL);

    expect(await nft.iiifManifestOf(0)).to.equal(newManifest);
  });

  it("オーナー以外はIIIF情報を更新できない", async function () {
    const { nft, owner, user1 } = await deployFixture();

    await nft.mintWithIIIF(
      owner.address,
      "ipfs://QmMetadata",
      MANIFEST_URI,
      CANVAS_ID,
      LABEL
    );

    await expect(
      nft
        .connect(user1)
        .updateIIIFInfo(0, "https://evil.com/manifest", "", "")
    ).to.be.revertedWithCustomError(nft, "OwnableUnauthorizedAccount");
  });
});

フロントエンドでIIIFビューアを統合する

NFTの詳細ページにMiradorビューアを埋め込み、IIIF Manifestをブラウザ上で表示します。

// src/components/IIIFViewer.tsx
"use client";

import { useEffect, useRef } from "react";
import { useReadContract } from "wagmi";

// IIIFCulturalNFTのABI(getIIIFInfo関数のみ)
const IIIF_NFT_ABI = [
  {
    inputs: [{ name: "tokenId", type: "uint256" }],
    name: "getIIIFInfo",
    outputs: [{
      components: [
        { name: "manifestURI", type: "string" },
        { name: "canvasId", type: "string" },
        { name: "label", type: "string" },
      ],
      type: "tuple",
    }],
    stateMutability: "view",
    type: "function",
  },
] as const;

const NFT_ADDRESS = process.env
  .NEXT_PUBLIC_IIIF_NFT_ADDRESS as `0x${string}`;

interface IIIFViewerProps {
  tokenId: number;
}

export function IIIFViewer({ tokenId }: IIIFViewerProps) {
  const viewerRef = useRef<HTMLDivElement>(null);

  const { data: iiifInfo } = useReadContract({
    address: NFT_ADDRESS,
    abi: IIIF_NFT_ABI,
    functionName: "getIIIFInfo",
    args: [BigInt(tokenId)],
  });

  useEffect(() => {
    if (!iiifInfo?.manifestURI || !viewerRef.current) return;

    // Miradorを動的にロード(Next.jsのSSR対策)
    import("mirador").then((Mirador) => {
      Mirador.default({
        id: viewerRef.current!.id,
        windows: [
          {
            manifestId: iiifInfo.manifestURI,
            canvasId: iiifInfo.canvasId || undefined,
          },
        ],
        window: {
          allowFullscreen: true,
          allowMaximize: false,
        },
      });
    });
  }, [iiifInfo]);

  if (!iiifInfo) {
    return <p className="text-gray-400">IIIF情報を読み込み中...</p>;
  }

  return (
    <div>
      <h3 className="text-lg font-semibold mb-2">
        {iiifInfo.label}
      </h3>
      <p className="text-sm text-gray-400 mb-4">
        Manifest: {iiifInfo.manifestURI}
      </p>
      <div
        id={`mirador-viewer-${tokenId}`}
        ref={viewerRef}
        className="w-full h-[500px] bg-black rounded-lg"
      />
    </div>
  );
}

Miradorの代わりにOpenSeadragonを使う場合は、Image APIのエンドポイントを直接指定します。

// OpenSeadragonを使う場合の例
import OpenSeadragon from "openseadragon";

useEffect(() => {
  if (!iiifInfo?.manifestURI) return;

  // ManifestからImage API URLを取得してビューアに設定
  fetch(iiifInfo.manifestURI)
    .then((res) => res.json())
    .then((manifest) => {
      // IIIF Presentation API 3.0の場合
      const canvas = manifest.items?.[0];
      const imageService =
        canvas?.items?.[0]?.items?.[0]?.body?.service?.[0];

      if (imageService) {
        OpenSeadragon({
          element: viewerRef.current!,
          tileSources: {
            "@context":
              "http://iiif.io/api/image/3/context.json",
            id: imageService.id,
            type: "ImageService3",
            profile: "level2",
          },
          showNavigator: true,
        });
      }
    });
}, [iiifInfo]);

IPFSにIIIF Manifestを保存する

IIIFマニフェスト自体をIPFSに保存することで、マニフェストの永続性も確保できます。

// scripts/uploadIIIFManifest.ts
import PinataSDK from "@pinata/sdk";

const pinata = new PinataSDK({
  pinataJwt: process.env.PINATA_JWT!,
  pinataGateway: process.env.PINATA_GATEWAY!,
});

async function uploadManifestToIPFS() {
  // IIIF Presentation API 3.0 形式のManifest
  const manifest = {
    "@context": "http://iiif.io/api/presentation/3/context.json",
    id: "https://example.org/iiif/choju-giga/manifest.json",
    type: "Manifest",
    label: { ja: ["鳥獣戯画 甲巻"] },
    metadata: [
      {
        label: { ja: ["所蔵"] },
        value: { ja: ["高山寺"] },
      },
      {
        label: { ja: ["時代"] },
        value: { ja: ["平安時代後期"] },
      },
    ],
    items: [
      {
        id: "https://example.org/iiif/choju-giga/canvas/p1",
        type: "Canvas",
        label: { ja: ["第1場面"] },
        height: 3000,
        width: 8000,
        items: [
          {
            id: "https://example.org/iiif/choju-giga/page/p1/1",
            type: "AnnotationPage",
            items: [
              {
                id: "https://example.org/iiif/choju-giga/annotation/p1-image",
                type: "Annotation",
                motivation: "painting",
                body: {
                  id: "https://example.org/iiif/choju-giga/full/max/0/default.jpg",
                  type: "Image",
                  format: "image/jpeg",
                  service: [
                    {
                      id: "https://example.org/iiif/choju-giga",
                      type: "ImageService3",
                      profile: "level2",
                    },
                  ],
                },
                target:
                  "https://example.org/iiif/choju-giga/canvas/p1",
              },
            ],
          },
        ],
      },
    ],
  };

  // IPFSにアップロード
  const result = await pinata.pinJSONToIPFS(manifest, {
    pinataMetadata: { name: "IIIF-Manifest-ChojuGiga" },
  });

  console.log("IIIF Manifest URI:", `ipfs://${result.IpfsHash}`);
  console.log(
    "HTTP URL:",
    `https://gateway.pinata.cloud/ipfs/${result.IpfsHash}`
  );

  return `ipfs://${result.IpfsHash}`;
}

uploadManifestToIPFS().catch(console.error);

まとめ

本章では、IIIFとNFTを統合したデジタルアーカイブシステムを構築しました。IIIF Manifest URIをオンチェーンに記録することで、ブロックチェーンの不変性(改ざん耐性)とIIIFの相互運用性(標準化されたビューア対応)を両立させています。

この仕組みにより、文化財のデジタル画像の所有権・来歴をブロックチェーンで管理しつつ、MiradorやOpenSeadragonなどのIIIFビューアで高品質な閲覧体験を提供できます。

次章では、デジタル保存の国際標準であるOAIS参照モデルとBagIt仕様に基づく、AIPパッケージの生成について学びます。

関連記事