【Unity】WebGLでCloudflare Imagesから画像をダウンロード/アップロードする

## 動作環境
Unity 2022.3.18f1 UniRx UniTask
Cloudflare API v4.0.0 https://developers.cloudflare.com/api/

・ホスティング
GitHub Pages　または　unityroom

・CORS回避用のプロキシサーバ
Cloudflare Wokers（node.js + Hono）
Wrangler (CLI)

## 費用(2024/2 時点)
`Cloudflare Images`
画像 100,000 枚あたり ＄5 (前払い)
配信された画像 100,000 枚あたり ＄1 (後払い)

## 概要
この記事で取り扱うこと
・`Cloudflare Images`への画像アップロード（ゲーム/エディタ画面のスクショ）
・`Cloudflare Images`から画像のIDリストをダウンロード
・`Cloudflare Images`から画像をID指定でダウンロード

また、`Cloudflare API`はクライアント環境ではCORSに弾かれ動作しないので、
プロキシサーバを作成する必要があります。

## デプロイ後の構成
WebGL(unityroomなど) <--> Wokers <--> Cloudflare API

## Cloudflare Wokersにプロキシサーバを作成する
・下記のHelloWorldが大変参考になりました。
注意点として、 Would you like to use TypeScript?は「yes」で作成します。
[Cloudflare WorkersをWranglerで構築してみる](https://zenn.dev/nnydtmg/articles/start-cloudflare-cli)

上記に加えて、[Hono](https://github.com/honojs/hono)というフレームワークをインストールします。
```
$ npm install hono
```

[FYI]
下記に、CORS回避のサーバ実装を記載します。
必要に応じて、CORSの設定やベーシック認証の記述を加えてください。
また、accountId及びapiTokenは、Cloudflareのダッシュボードから取得したものを記入してください。

<details><summary>プロキシサーバの記述例</summary><div>

```typescript:src/index.ts
import { Hono } from "hono";
import { cors } from 'hono/cors'

const accountId = "";
const apiToken = "";

const app = new Hono();

app.use(
	'*',
	cors({
      origin: "*",
	  allowHeaders: ["*"],
	  allowMethods: ['POST', 'GET', 'OPTIONS'],
	  maxAge: 600,
	  credentials: true,
	})
);

app.get('/image/:imageId',async (c) => {
	const { imageId } = c.req.param();
    const init = {
		method: 'GET',
		headers: {
			"Authorization": `Bearer ${apiToken}`,
			"Content-Type": "application/json"
		}
	} as RequestInit;

	const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}/blob`, init);
	const newResponse = new Response(response.body, response);
	return newResponse;
});

app.get('/imageList', async (c) => {
	console.log("hoge");
    const init = {
		method: 'GET',
		headers: {
			"Authorization": `Bearer ${apiToken}`,
			"Content-Type": "application/json"
		}
	} as RequestInit;

	const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v2`, init);
	const newResponse = new Response(response.body, response);
	return newResponse;
});

app.post('/upload', async (c) => {
	const body = await c.req.parseBody();

	const form = new FormData();
	form.append("file", body.file as Blob, "result.jpg");
	form.append("requireSignedURLs", "true");

	const init = {
		method: 'POST',
		headers: {
			"Authorization": `Bearer ${apiToken}`,
		},
		body: form
	} as RequestInit;

	const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`, init);

	const newResponse = new Response(response.body, response);
	return newResponse;
});

export default app;
```
</div></details>

上記を実装後、下記のコマンドでCloudflareへデプロイできます。
```
$ npx wrangler publish
```

## `Cloudflare Images`へ画面スクショをアップロードする
作成したプロキシサーバ経由で、アップロードします。
### ■Unityの画面スクショの撮り方
知ってる方は飛ばしてください。

RectTransformで幅と高さを指定して、Texture2Dに変換してくれます。
複数解像度に対応しております。

<details><summary>画面スクショを撮るクラスの記述例</summary><div>

```C#:ScreenShoter.cs
    using Cysharp.Threading.Tasks;
    using UnityEditor;
    using UnityEngine;

    public class ScreenShoter : MonoBehaviour
    {
        public async UniTask<Texture2D> TakeScreenShot(RectTransform rect)
        {
            // 描画が完了してからでないとエラーが出る
            await UniTask.WaitForEndOfFrame(this);

            var screenWidth = Screen.width;
            var screenHeight = Screen.height;
#if UNITY_EDITOR
            string[] res = UnityStats.screenRes.Split('x');
            screenWidth = int.Parse(res[0]);
            screenHeight = int.Parse(res[1]);
#endif
            var corners = new Vector3[4];
            rect.GetWorldCorners(corners);
            corners[0] = RectTransformUtility.WorldToScreenPoint(Camera.main, corners[0]);
            var _s = screenWidth / 1920.0f; // 横長に合わせる
            var w = rect.sizeDelta.x * _s;
            var h = rect.sizeDelta.y * _s;

            var texture = new Texture2D((int)w, (int)h);
            texture.ReadPixels(new Rect(corners[0].x, corners[0].y, w, h), 0, 0);
            texture.Apply();
            return texture;
        }
    }
```
</div></details>

### ■アップロード
こちらも、コード例を記載します。
hostは、デプロイしたCloudflare Wokersのドメインを指定してください。
e.g. `https://{YOUR_PATH}.wokers.dev`

ついでに.jpgに圧縮しています。

<details><summary>Texture2Dをサーバにアップロードするクラスの記述例</summary><div>

```C#:WebImageUploader.cs
using Cysharp.Threading.Tasks;
using System;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

public class WebImageUploader : MonoBehaviour
    {
        private const string host = "";
        private string uploadPath = "/upload";

        public async UniTask Upload(Texture2D tex)
        {
            WWWForm form = new WWWForm();
            form.AddBinaryData("file", tex.EncodeToJPG(), "result.jpg", "image/jpg");

            var url = host + uploadPath;
            using (var request = UnityWebRequest.Post(url, form))
            {
                await request.SendWebRequest();

                switch (request.result)
                {
                    case UnityWebRequest.Result.ConnectionError:
                        Debug.Log($"サーバとの通信に失敗: {request.error}");
                        break;
                    case UnityWebRequest.Result.DataProcessingError:
                        Debug.LogError($"データの処理中にエラー: {request.error}");
                        break;
                    case UnityWebRequest.Result.ProtocolError:
                        Debug.LogError($"HTTPのエラー: {request.error}");
                        break;
                    case UnityWebRequest.Result.Success:
                        Debug.Log("成功");
                        break;
                }
            }
        }
    }
```
</div></details>

## Cloudflare Imagesから画像のIDリストをダウンロードする
コード例を記載します。
hostは、デプロイしたCloudflare Wokersのドメインを指定してください。
e.g. `https://{YOUR_PATH}.wokers.dev`

<details><summary>画像のIDリストをダウンロードするクラスの記述例</summary><div>

```C#:WebImageDownloader.cs
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UniRx;
using UnityEngine;
using UnityEngine.Networking;

public class WebImageDownloader : MonoBehaviour
{
    private const string host = "";
    private string imageListDownloadPath = "/imageList";

    public async UniTask<List<string>> DownloadImageIdList(int page = 1, int perPage = 10)
    {
        var queryString = System.Web.HttpUtility.ParseQueryString("");
        queryString.Add("page", page.ToString());
        queryString.Add("perPage", perPage.ToString());

        var url = host + imageListDownloadPath;
        var uriBuilder = new UriBuilder(url)
        {
            Query = queryString.ToString()
        };
        using (var request = UnityWebRequest.Get(uriBuilder.Uri))
        {
            request.SetRequestHeader("Content-Type", "application/json");
            await request.SendWebRequest();

            switch (request.result)
            {
                case UnityWebRequest.Result.ConnectionError:
                    Debug.Log($"サーバとの通信に失敗: {request.error}");
                    break;
                case UnityWebRequest.Result.DataProcessingError:
                    Debug.LogError($"データの処理中にエラー: {request.error}");
                    break;
                case UnityWebRequest.Result.ProtocolError:
                    Debug.LogError($"HTTPのエラー: {request.error}");
                    break;
                case UnityWebRequest.Result.Success:
                    Debug.Log("成功");
                    string json = request.downloadHandler.text;
                    ResponseModel response = JsonUtility.FromJson<ResponseModel>(json);
                    return response.result.images.ToList().Select(x => x.id).ToList();
            }

            return new List<string>();
        }
    }

    [Serializable]
    private class ResponseModel
    {
        public ResponseResult result;
    }

    [Serializable]
    private class ResponseResult
    {
        public ResponseImages[] images;
    }

    [Serializable]
    private class ResponseImages
    {
        public string id;
    }
}
```
</div></details>

## Cloudflare Imagesから画像IDを指定してダウンロードする
コード例を記載します。
hostは、デプロイしたCloudflare Wokersのドメインを指定してください。
e.g. `https://{YOUR_PATH}.wokers.dev`

画像ダウンロード完了時に、
`OnDownloadImage`を発火することで非同期処理に対応しています。

<details><summary>画像のIDリストをダウンロードするクラスの記述例</summary><div>

```C#:WebImageDownloader.cs
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UniRx;
using UnityEngine;
using UnityEngine.Networking;

public class WebImageDownloader : MonoBehaviour
{
    private const string host = "";
    private string imageDownloadPath = "/image";
    
    public IObservable<byte[]> OnDownloadImage => _onDownloadImage;
    private Subject<byte[]> _onDownloadImage = new Subject<byte[]>();

    public async UniTask<byte[]> DownloadImage(string imageId)
    {
        var url = host + imageDownloadPath + "/" + imageId;
        using (var request = UnityWebRequest.Get(url))
        {
            await request.SendWebRequest();

            switch (request.result)
            {
                case UnityWebRequest.Result.ConnectionError:
                    Debug.Log($"サーバとの通信に失敗: {request.error}");
                    break;
                case UnityWebRequest.Result.DataProcessingError:
                    Debug.LogError($"データの処理中にエラー: {request.error}");
                    break;
                case UnityWebRequest.Result.ProtocolError:
                    Debug.LogError($"HTTPのエラー: {request.error}");
                    break;
                case UnityWebRequest.Result.Success:
                    Debug.Log($"成功");
                    _onDownloadImage.OnNext(request.downloadHandler.data);
                    return request.downloadHandler.data;
            }
        }

        return new byte[0];
    }
}
```
</div></details>


### ■ダウンロードしたblobを、uguiのImageやSpriteに貼り付ける
オマケです。
Texture2Dのリサイズや、Texture2DをSpriteに変換したりしています。

<details><summary>ダウンロードしたblobをImageやSpriteに貼り付ける処理の記述例</summary><div>

```C#
using Cysharp.Threading.Tasks;
using System.Collections;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class GalleryFactory : MonoBehaviour
{
    [SerializeField] WebImageDownloader webImageDownloader;
    [SerializeField] Transform gallerySpawnTransform;
    [SerializeField] Image galleryImagePrefab;

    void Start()
    {
        webImageDownloader.OnDownLoadImage
            .Subscribe(blob => SpawnGallery(blob))
            .AddTo(this);

        webImageDownloader.DownloadImageRecentry().Forget();
    }

    private void SpawnGallery(byte[] blob)
    {
        Texture2D texture = new Texture2D(2, 2);
        texture.LoadImage(blob);

        var obj = Instantiate(galleryImagePrefab, gallerySpawnTransform);
        var resizeTexture = ResizeTexture(texture, (int)obj.rectTransform.sizeDelta.x, (int)obj.rectTransform.sizeDelta.y);
        obj.sprite = Sprite.Create(resizeTexture, new Rect(0, 0, resizeTexture.width, resizeTexture.height), Vector2.zero);
    }

    Texture2D ResizeTexture(Texture2D originalTexture, int newWidth, int newHeight)
    {
        RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight);
        RenderTexture.active = rt;
        Graphics.Blit(originalTexture, rt);

        Texture2D resizedTexture = new Texture2D(newWidth, newHeight);
        resizedTexture.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
        resizedTexture.Apply();

        RenderTexture.active = null;
        RenderTexture.ReleaseTemporary(rt);

        return resizedTexture;
    }
}
```
</div></details>

オマケのオマケです。
`WebImageDownloader.DownloadImageRecentry()`は、下記のような実装をしました。

<details><summary>WebImageDownloader.DownloadImageRecentry()</summary><div>

```C#:WebImageDownloader.cs
...省略
    public async UniTask<List<byte[]>> DownloadImageRecentry(int page = 1, int perPage = 10)
    {
        var imageIds = await DownloadImageIdList(page, perPage);
        var blobs = new List<byte[]>();
        foreach (var imageId in imageIds)
        {
            var blob = await DownloadImage(imageId);
            if (blob.Length > 0)
            {
                blobs.Add(blob);
            }
        }
        return blobs;
    }
```
</div></details>


## 宣伝
グローバルゲームジャム2024で制作したゲームをunityroomで公開しました！
宜しければ遊んでください～！
https://unityroom.com/games/fukuwarai_panic