はじめに
OpenSeaには色々な NFTが並んでいます。
画像の NFTだけではなく、動画、3D、サウンドなど、色々な形式のデータを扱ってくれるようです。
NFTの情報はメタデータで記述されるということを、前回の投稿で説明いたしました。例えば、画像なら "image"要素で指定され、PNG、JPG、GIF(アニメGIF)だけでなく、SVGにも対応しているようです。一方で、動画や3Dといった画像以外の要素は、"animation_url"として、"image"とは別に指定されます。
OpenSeaのメタデータの詳細ページで "animation_url"を確認すると、色々な拡張子がのっています。
A URL to a multi-media attachment for the item. The file extensions GLTF, GLB, WEBM, MP4, M4V, OGV, and OGG are supported, along with the audio-only extensions MP3, WAV, and OGA.
Animation_url also supports HTML pages, allowing you to build rich experiences and interactive NFTs using JavaScript canvas, WebGL, and more. >Scripts and relative paths within the HTML page are now supported. However, access to browser extensions is not supported.
"animaton_url"はずいぶんと芸達者な要素のようです。
この記事では、そんな "animation_url"の各種フォーマットのテストをしてみたいと思います。
また、**HTML(+JavaScript)**によって広がる NFTの可能性についても探ってみたいと思います。
"animation_url" 対応フォーマット
まずは、"animation_url"が対応する各種データを、Rinkebyでテストしてみました(テストデータはフリーのものをお借りしました & NFTの詳細に出典を書いてあります)。
https://testnets.opensea.io/collection/file-format-test-token
・GLTF (3Dモデルデータ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/0
・GLB (3Dモデルデータ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/1
・WEBM (動画データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/2
・MP4 (動画データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/3
・M4V (動画データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/4
音が出ます
・OGV (動画データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/5
OGVはうまく再生されませんでした(音声有り/音声なしの両方でテスト)。
ちなみに、IPFSへアップロードしたデータの再生は正常に行われるので OpenSea側の問題のように思われます。
https://bafybeigxesrikogeqzlisjviugt72lalgmgokmaby66f2xjkxh5fxqcwv4.ipfs.infura-ipfs.io/05_ogv.ogv
音が出ます
・OGG (動画データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/6
音が出ます
・MP3 (音声データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/7
音が出ます
・WAV (音声データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/8
音が出ます
・OGA(音声データ)
https://testnets.opensea.io/assets/0x17ec0065e83c78f82eb8d4a3f52291a3321e5005/9
音が出ます
一通りのフォーマットを試したところ、OGV以外はとくに問題なく表示/再生されました。
とはいえ、テストしたのは Rinkebyだけで、テストデータも1種類のみとなります。
OGVにかぎらず、"animaton_url"の利用を考えている方は、デプロイを予定しているチェーン上で、実際のデータと同じ形式のダミーデータが正常に表示されるか確認することをお勧めします(個人的にOpenSeaは、Ethereum系(Mainnet/Rinkeby)と、Polygon系(Polgyon/Mumbai)で微妙に実装が違う印象がありまして、Rinkebyで動いたけど、Polygonで動かないなんてことも実際にあったりしたので)。
可能性の塊 HTML+JavaScript
さて、真打登場です。
"animation_url"には HTMLが指定できます。
そして、HTML内で JavaScriptのコードを書くことができてしまいます。
あんなことや、こんなことが、OpenSeaの詳細ページ上で行えてしまうというわけです。
これは、結構「こわい」ことだと思うのですが、やっていいというなら、やらせていただきましょう!
(まあ、Plugin等の処理が使えないなど、いろいろと制限はあるようですので、イタズラはできないと思いますが)
メリークリスマス
というわけで、テストとして「クリスマスプレゼント」の NFTを作ってみました。
https://testnets.opensea.io/collection/xmas-present-token-2021
決まった時間が来るまでは、中身が見えない NFTとなります。
まぁ、なんて素敵な NFTでしょう!?
この NFT、なんと今なら 0.1 ETHでお買い求めいただけるんです!
(Rinkebyですけどね...)
solidity 側の実装
スマートコントラクト側の実装はとてもシンプルで、指定された時間になるまで、tokenURIからは「中身を隠した」メタデータを返却します。
で、指定された時間が経過したら、tokenURIからは「中身の見える」メタデータを返却するようにします。
(メタデータの作成に関しては前回の記事を参照ください)
// 時間が経過しているので "image"に中身の画像を指定する
// "animation_url"は不要
if( _unixtimes[tokenId] <= block.timestamp ){
bytesImage = abi.encodePacked( ',"image":"https://ipfs.infura.io/ipfs/', PNG_HASH ,'/p', bytesId, '.png"' );
bytesAnimationUrl = '';
}
// 時間が経過していないので "image"には箱の画像を指定する
// "animation_url"で待機表示用のHTMLを指定する
else{
bytesImage = abi.encodePacked( ',"image":"https://ipfs.infura.io/ipfs/', GIF_HASH, '/b', bytesId, '.gif"' );
bytesAnimationUrl = abi.encodePacked( ',"animation_url":"https://ipfs.infura.io/ipfs/', _htmls[tokenId], '"' );
}
ここで、"animation_url"に指定するHTMLにより、OpenSea上での NFTの表示が「中身を隠した」状態となります。
HTML側の実装
HTML(JavaScript)の処理としては下記となります。
・指定された時間までのカウントダウンを行う
・指定された時間が経過していたらボタンを表示する
・ボタンが押されたらOpenSeaのAPI「refresh metadata」を叩く
コードとしては下記のようになります。
<html>
<head>
<style>
<!-- 省略 -->
</style>
<script>
let waitSec = 0;
let intervalId = 0;
// カウントダウンの処理
function count(){
if( waitSec <= 0 ){
clearInterval( intervalId );
ready();
return;
}
waitSec--;
let day = Math.floor(waitSec/86400);
let hh = (Math.floor(waitSec/3600))%24;
hh = ( "00" + hh ).slice( -2 );
let mm = (Math.floor(waitSec/60))%60;
mm = ( "00" + mm ).slice( -2 );
let ss = (waitSec%60);
ss = ( "00" + ss ).slice( -2 );
let e = document.getElementById( "sT" );
if( day <= 0 ){
e.innerHTML = hh + ":" + mm + ":" + ss;
}else{
if( day > 1 ){
e.innerHTML = day + "days " + hh + ":" + mm + ":" + ss;
}else{
e.innerHTML = day + "day " + hh + ":" + mm + ":" + ss;
}
}
}
// 時間が経過していない場合の「WAIT」表示
function wait(){
let e = document.getElementById( "pR" );
e.style.display = "none";
e = document.getElementById( "sW" );
e.innerHTML = "Please Wait...";
count();
intervalId = setInterval( count, 1000 );
}
// 時間が経過している場合の「OPEN」表示
function ready(){
let e = document.getElementById( "pW" );
e.style.display = "none";
e = document.getElementById( "pR" );
e.style.display = "block";
e = document.getElementById( "sR" );
e.innerHTML = "Open the Gift!!";
e = document.getElementById( "bO" );
e.style.display = "inline";
}
// ページが読み込まれた際の処理
function start(){
let ut = 1639753200;
let t = Math.floor(new Date().getTime() / 1000);
if( t < ut ){
waitSec = ut - t;
}
if( waitSec > 0){
wait();
}else{
ready();
}
}
// 「OPEN」ボタンが押された時の処理
function updateMeta() {
let e = document.getElementById( "bO" );
e.style.display = "none";
e = document.getElementById( "iL" );
e.style.display = "inline";
e = document.getElementById( "sR" );
e.innerHTML = "Updating...";
setTimeout( notice, 8000 );
// このNFTの「refresh metadata]APIのアクセス先
let url = "https://rinkeby-api.opensea.io/api/v1/asset/0x29E76047aF56D87aB89c6E888D14f62FfC0A5235/0/?force_update=true";
callRefreshMetadata( url );
}
// 指定されたURLのフェッチ(APIを叩く)
async function callRefreshMetadata( url ){
await fetch( url );
}
// 更新完了通知
function notice(){
let e = document.getElementById( "iL" );
e.style.display = "none";
e = document.getElementById( "sR" );
e.innerHTML = "Reload the Page<br>After a While!!";
}
</script>
</head>
<body onload="start()">
<p id="pW">
<span id="sW"></span><br>
<span id="sT" class="time"></span>
</p>
<p id="pR">
<span id="sR"></span><br>
<img id="bO" class="bO" onclick="updateMeta()" src="https://ipfs.infura.io/ipfs/QmZq251SckKKb7ZE89EhDFmcgpXe5DzfHjHd2c4JsYyC3a">
<img id="iL" class="iL" src="https://ipfs.infura.io/ipfs/QmbfNLk7dH8PoWsdW321VWBpVv5sUPHzp4Pk5VYeAnDz9g">
</p>
</body>
</html>
実装としては、本当に、普通の HTML+JavaScriptを書くのと同じです。
ローカルでブラウザ上に HTMLファイルを読み込ませつつ、希望の挙動を確認できたら、IPFS等へアップロードして、NFTへ割り当てるだけです。
OpenSea上での流れとしては、下記となります。
ちなみに、プレゼントボックスは誰でも開けることができ、ガス代もかかりません(メタデータの更新を呼ぶだけなので)。
1日に1つか2つのプレゼントボックスが開けられるようになっているので、興味のある方はコレクションページでクリスマス気分をお楽しみくださいませ。
おわりに
"animation_url"の提供する各種ファイルフォーマットですが、人によってはあまり使う機会はないかもしれません。
一方で、HTML(JavaScript)を使うことで、本来の NFTの価値に+アルファをもたせられそうです。
今回テストしたクリスマスプレゼントの NFTは、実際には PNGが割り当てられただけの、ごく普通のNFTです。
ですが、JavaScriptによる一手間で「待つ楽しさ」と「開ける楽しさ」の2つが提供できたと思います。
"animation_url"には、工夫とアイデア次第で、色々な可能性が秘められているのです。
補足① ソースコード
今回テストした2つのコントラクトのコードは githubへあげてあります
・https://github.com/hakumai-iida/AnimationUrlFormatTest
・https://github.com/hakumai-iida/PresentBoxNft
補足② HTMLとはモノなのか?
どうでもよい話なんですが...。
例えば、GLB、MP4、WAVといったデータの場合、3Dモデル、動画、音声として、明確に「モノ」としてイメージできるじゃないですか?
NFTの保有者も「私はこの3Dモデル/動画/音声を持っている」と違和感なく認識できると思うのですよね。
では、「モノ」としてみた場合、HTMLとはなんなんでしょうか?
テキストベースなページであったり、画像が掲載されたページであったり、リンク集だったり、千差万別です。
うーん、そういう意味では「情報」なんでしょうか?
一方で、JavaScriptが入ってくる場合、また少し、毛色が変わってきます。
タイマー的な処理をしたり、入力を判定したり、外部APIを叩いたり...。
こちらの場合は、「仕組み」とでも考えるべきでしょうか?
今回、記事を書いていて少しモヤモヤしました。
うーん、不思議。