【VRChat】UdonSharpのImage Loadingを使って自動更新される写真立てを作る

# 概要
VRChatにImage Loadingが公開され、Webから画像を取得できるようになりました。
VRChatterはみんな写真が大好きで、ホームワールドに写真をたくさん飾る習性があります。
Unityを開かなくても飾った写真が自動的に更新できると嬉しいですね。
こんな感じです。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ChatGPTに頼んでGoogledriveに突っ込んだ写真から週替わりでランダムに飾ってくれるやつ作ってもらった<br>超便利🥳<br>(動画4倍速) <a href="https://t.co/H2DtpuUTpF">pic.twitter.com/H2DtpuUTpF</a></p>&mdash; なまのなまこ (@Iiron_Lung) <a href="https://twitter.com/Iiron_Lung/status/1645024085888614400?ref_src=twsrc%5Etfw">April 9, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

作ってみましょう。

全体像はこちら
![shikumi.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/8e626cc2-eda8-f7ae-3ec4-fd45f8a6e7ae.jpeg)


# ステップ1　手動で差し替えできる写真立てを置く
### 使うサービス
- DropBox
VRChatの許可リストに入っている。
かつファイルのURLがフォルダ・ファイル名によって一意に決まるのでワールドを更新することなく画像の差し替えが可能。 

### 作り方
Udonの部分は省略します。
ImageLoadingを使ってWebから画像を読み込める機能を作ってください。
ここにサンプルを置いておきます。

https://namanonamako.booth.pm/items/4702922

DropBoxに飾りたい写真をアップロードし、リンクをコピーします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/9025beb6-e605-7c0b-36dc-6af6d6c8b0c7.png)

コピーしたリンクのURLを編集します。
「www.dropbox.com」を「dl.dropboxusercontent.com」に変更し、末尾の「?dl=0」を削除してください。

```:例
https://www.dropbox.com/s/hogefuga/1.png?dl=0
↓
https://dl.dropboxusercontent.com/s/hogefuga/1.png
```

編集後のURLをImageLoadingで読み込んでください。
差し替える際はDropboxの元のファイルを削除し、差し替えたいファイルを元のファイルと同じ名前にしてアップロードすることでワールド更新なしで反映されます。

# ステップ２　自動で定期更新されるようにする
Dropboxの写真をGoogleDriveからランダムに選んで差し替えるようにします。
### 使うサービス
- GAS
無料のスクリプトサービス。スケジュールで自動実行してくれる。
### GASを作る
GoogleDriveを開き「新規→その他→GoogleAppsScript」を選び新規スクリプトを作成します。
コード.gsに以下のコードをコピペしてください。
コードはChatGPTが作りました。

```javascript:GD2DB.gs
var auth_code = 'AUTH_CODE';
var folderId = 'FOLDER_ID';
var CLIENT_ID = 'CLIENT_ID';
var CLIENT_SECRET = 'CLIENT_SECRET';
var dropboxFolderName = 'FOLDER_NAME';
var reflesh_token = 'REFLESH_TOKEN';

// Google Driveのフォルダからランダムに10枚の画像を取得し、Dropboxにアップロード
function getRandomImagesAndUploadToDropbox() {
  var folder = DriveApp.getFolderById(folderId);
  var files = folder.getFiles();
  var imageUrls = [];

  var dropboxAccessToken = requestToken(CLIENT_ID, CLIENT_SECRET, reflesh_token);

  // Google Driveのフォルダ内の画像URLを取得
  while (files.hasNext()) {
    var file = files.next();
    var mimeType = file.getMimeType();
    if (mimeType.startsWith('image/')) {
      imageUrls.push({url: file.getUrl(), id: file.getId(), name: file.getName()});
    }
  }

  // 画像URLからランダムに10枚選択
  var randomImages = [];
  for (var i = 0; i < 10 && imageUrls.length > 0; i++) {
    var randomIndex = Math.floor(Math.random() * imageUrls.length);
    randomImages.push(imageUrls.splice(randomIndex, 1)[0]);
  }

  // ランダムに選択された画像をDropboxにアップロード
  for (var i = 0; i < randomImages.length; i++) {
    var image = randomImages[i];
    var file = DriveApp.getFileById(image.id);
    var blob = file.getBlob();
    var fileName = (i + 1) + '.png';
    uploadToDropbox(dropboxAccessToken, dropboxFolderName, fileName, blob);
  }
}

function requestToken(key, secret, token) {
  // Request a token
  const message = "grant_type=refresh_token&refresh_token=" + token + "&client_id=" + key + "&client_secret=" + secret;
  const options = {
    method: "post",
    contentType: "application/x-www-form-urlencoded",
    payload: message,
    muteHttpExceptions: true,
  };
  const response = UrlFetchApp.fetch("https://api.dropboxapi.com/oauth2/token", options);

  // Decode the response body (json format)
  const responseData = JSON.parse(response.getContentText());
  const access_token = responseData["access_token"];

  if (access_token !== undefined) {
    return access_token;
  } else {
    const error = responseData["error_description"];
    console.log("Failed to get access token. Error: " + response.getResponseCode() + ": " + error);
    return null;
  }
}

function GetRefreshToken(){
    // Request a token
  const message = "grant_type=authorization_code&code=" + auth_code + "&client_id=" + CLIENT_ID + "&client_secret=" + CLIENT_SECRET;
  const options = {
    method: "post",
    contentType: "application/x-www-form-urlencoded",
    payload: message,
    muteHttpExceptions: true,
  };
  const response = UrlFetchApp.fetch("https://api.dropboxapi.com/oauth2/token", options);

  // Decode the response body (json format)
  const responseData = JSON.parse(response.getContentText());
  const access_token = responseData["access_token"];

  if (access_token !== undefined) {
    console.log(responseData);
  } else {
    const error = responseData["error_description"];
    console.log("Failed to get access token. Error: " + response.getResponseCode() + ": " + error);
    return null;
  }
}

// 画像をDropboxにアップロードする関数
function uploadToDropbox(accessToken, folderName, fileName, blob) {
  var url = 'https://content.dropboxapi.com/2/files/upload';
  var headers = {
    'Authorization': 'Bearer ' + accessToken,
    'Content-Type': 'application/octet-stream',
    'Dropbox-API-Arg': JSON.stringify({
      path: '/' + folderName + '/' + fileName,
      mode: 'overwrite', // 上書きモードに設定
      autorename: false, // 自動リネームを無効に設定
      mute: false
    })
  };

  var options = {
    method: 'post',
    headers: headers,
    payload: blob.getBytes(),
    muteHttpExceptions: true
  };

  var response = UrlFetchApp.fetch(url, options);
  var statusCode = response.getResponseCode();
  var responseBody = JSON.parse(response.getContentText());

  if (statusCode === 200) {
    Logger.log('File uploaded successfully: ' + responseBody.name);
  } else {
    Logger.log('Error uploading file: ' + responseBody.error_summary);
  }
}
```

### 各種情報の取得
上記コードの認証情報部分を書き換えていきます。
- GoogleDrive
飾りたい写真を入れたGoogleDriveのフォルダを開きURLの最後の部分をコピーし、folderIdに設定します。
```:例
https://drive.google.com/drive/u/5/folders/hogefugaの場合
 var folderId = 'hogefuga';
```
- ドロップボックス
[Dropbox開発者向け情報のページ](https://www.dropbox.com/developers/)のCreatappsをクリックし、以下のように設定してAPPとフォルダを作成します。
名前は任意で入れてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/144aeb62-5307-54c4-894a-a7b662e8c6e4.png)
AppConsoleから作ったAppを選びApp keyとApp secret を取得します。
![dropbox.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/3ef510c2-0cee-4a1a-e3d4-85db0de9dac7.jpeg)
```:例
  var CLIENT_ID = '取得したApp key';
  var CLIENT_SECRET = '取得したApp secret';
```

Permissionsのタブを開き「files.content.write」にチェックを入れたうえで画面下部のSubmitボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/8f30d12e-de63-f8c7-94fe-56244ba43f33.png)


### RefleshTokenの取得
DropboxのTokenの有効時間が4時間に設定されているため、refleshTokenを取得し処理毎にTokenを再取得できるようにします。
auth_codeは使い切りなので途中で中断・コピペを失敗した等があった場合auth_codeの取得からやり直してください。

取得したApp keyをもとに以下のURLを修正し、Webブラウザでアクセスしてください。
```:例 取得したApp keyがhogeだった場合
  https://www.dropbox.com/oauth2/authorize?client_id=hoge&response_type=code&token_access_type=offline
```
画面の「許可」をクリックし、出てきたauthorization codeをGASのauth_codeに入力します。
```:例 auth_codeがFOO_BARだった場合
var auth_code = 'FOO_BAR'
```
GASのプロジェクトを保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/31809919-0ebf-748c-685f-880744307eba.png)

デバッグの右側にある▼マークを押し「GetRefleshToken」を選んだうえで「実行」ボタンを押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/2600cd44-2c63-e79e-90f4-659dd27d3174.png)
画面下部のLogに情報として文字列が出てきます。
その中で refresh_token:と書かれている部分の右側の文字列をGASに張り付けます。

```:例 refresh_token:'hoge';だった場合
var reflesh_token = 'hoge';
```

dropboxFolderNameは好きな名前を入れてください。
```:例 好きな名前がnekochanだった場合
var dropboxFolderName = 'nekochan';
```

# 実行
再度GASを保存し、getRandomImagesAndUploadToDropboxを選択して「実行」ボタンを押してください。
順次ファイルがDropBoxにアップロードされます。

DropBoxには『アプリ/アプリ名/GASのdropboxFolderNameで設定したフォルダ名』の中にアップロードされているので、ステップ1の手順でURLを取得しVRChatから読み込めば完了です。

### スケジュール
GASはスケジュールで自動実行してくれる機能があります。
画面左側の時計マーク、トリガーを選びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/ab3ea96f-9f86-c20d-a163-657ff7264216.png)
好きなタイミングでgetRandomImagesAndUploadToDropboxが動くようにすれば自動更新が始まります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495102/16515714-3499-c9af-641c-5f3ca931de41.png)

山のような写真を厳選することもUnityを触る必要もなく、毎日違った思い出に浸ることができます。
Happy!

# 参考文献
https://www.nagahitoyuki.com/2022/02/how-to-get-a-direct-link-url-in-dropbox.html

https://laboratory.kazuuu.net/get-a-developer-account-to-use-the-dropbox-api/

https://navi.dropbox.jp/dropbox-api-migrating-app-permissions-and-access-tokens#03