[Cloudflare Workers AI] 星空の画像を生成して返すAPIを作ってみた

Cloudflare Workers AIを使って星空の画像を生成するエンドポイントを開発してみました。この記事ではその手順や手こずった場所などをご紹介します。

## できたもの
![星空サンプル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/487beaaf-fd66-7bd2-f890-c14e417a9484.png)

このような星空画像が生成されるエンドポイントができました。

### デモ

https://workergen2.dsgamer777.workers.dev
(アドレスがすごく雑なのは気にしないでください)

上記のアドレスにアクセスすると 3~10秒ほどで星空っぽいPNG画像が返ってきます。
※ 無料枠で運用しているので、あまりにもアクセスが多いと止まる可能性があります。

## 利用したもの
- サービス
  - Cloudflare Workers AI
- 生成モデル
  - `@cf/lykon/dreamshaper-8-lcm` (StableDiffusion系 TextToImage LCMモデル)
- 時間
  - コードを書くのに1時間程度、環境構築と設定に2時間程度
- 費用
  - (使っているモデルがbetaなので) 無料

## 開発&デプロイの手順

### 注意
後述しますが、まだプロダクションでの利用はおすすめしにくいです。
特に今回使用したモデルは`beta`であることに注意しましょう。

### 用意するもの
- Cloudflareアカウント
- Node.js 20
- VSCode 等のエディタ

### 開発手順
- 1 `npm install wrangler -g` で Cloudflare Workersの開発ツールキットをインストール
- 2 プロジェクトを作成したいフォルダ内に移動し、端末を開く
- 3 `wrangler init <project名>` でプロジェクトを作成
- 4 作成するプロジェクトテンプレートを訊かれるので、`"Hello World" Worker` を選択したまま Enter
- 5 TypeScriptを使うか訊かれるので、もちろん`Yes`のまま Enter
- 6 Node.jsのプロジェクトが構築されるので、しばらく待つ
- 7 Gitリポジトリを初期化するか訊かれるので、使う場合は`Yes` を選択したままEnter
- 8 最後に このままデプロイするかを訊かれるので、とりあえずデプロイする場合 Enter
  - (9番のログインを行った後) `Hello World!`と返されるエンドポイントができます
- 9 `wrangler login` で Cloudflareアカウントにログイン
- 10 できたフォルダ内に遷移し、`npm install wrangler@3.61.0` で wranglerをダウングレード (後述)
- 11 wrangler.tomlの `[ai]` と `binding = "AI"` のコメントアウトを外し、AI機能を有効化
- 12 `src/index.ts` を開き、Cloudflare Worker docsを参考にスクリプトを書く
- 13 `yarn run dev` で `http://127.0.0.1:8787` で動くローカル環境を起動します
  - ローカルで実行している場合、端末にconsole.log結果が表示されます
  - 現状生成AIモデルはローカル実行未対応のため、ローカル環境であっても費用がかかります
  - [無料枠ではbetaモデルは完全無料、それ以外はneuron(1日毎に回復)を消費して無料です](https://developers.cloudflare.com/workers-ai/platform/pricing/)

### デプロイ手順
- 1 作成したプロジェクトフォルダ内で、端末を開く
- 2 `npm run deploy` で デプロイ完了🎉
- 3 端末に表示されるアドレスを共有するか、Cloudflareのダッシュボード、Workers ルートから任意のドメインに割当してアクセスすると、作成したWorkerを利用できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/b8c26166-9ae1-f7f8-4c46-0510e428f372.png)


## ソースコード
<details>
<summary>長いので折りたたみ
</summary>

```typescript:src/main.ts
export interface Env {
	// If you set another name in wrangler.toml as the value for 'binding',
	// replace "AI" with the variable name you defined.
	AI: Ai;
}

const BASE_KEYWORDS = [
	'stars',
	'galaxies',
	'nebulae',
	'cosmic dust',
	'deep space',
	'vibrant colors',
	'high resolution',
	'distant planets',
	'glowing effects',
	'shooting star',
	'milky way',
	'dark background',
	'3D rendering',
	'ethereal atmosphere',
];

const PROMPT_BASE = ['space wallpaper', 'anime'];
const DESIRED_SIZE_MIN = 100; // KB
const MAX_ATTEMPTS = 3;
const RETRY_DELAY = 100; // ms

// ランダムなプロンプトを生成して返す
async function generatePrompt(): string {
	const promptWords = [...PROMPT_BASE];
	for (let i = 0; i < 10; i++) {
		promptWords.push(BASE_KEYWORDS[Math.floor(Math.random() * BASE_KEYWORDS.length)]);
	}
	return promptWords.join(', ');
}

// ReadableStreamをUint8Arrayに変換して返す
async function readStream(stream: ReadableStream): Promise<Uint8Array> {
	const reader = stream.getReader();
	const chunks: Uint8Array[] = [];
	while (true) {
		const { value, done } = await reader.read();
		if (done) break;
		if (value) chunks.push(value);
	}
	return new Uint8Array(chunks.flatMap((chunk) => Array.from(chunk)));
}

// 指定した時間待機する
function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

// 画像を生成し、その結果をUint8Arrayで返す
async function generateImage(env: Env, prompt: string, guidance: number): Promise<Uint8Array> {
	// NOTE: Default guidance is 7.5 but it should be between 1.0 and 2.0!!
	const response = await env.AI.run('@cf/lykon/dreamshaper-8-lcm', { prompt, guidance });
	return await readStream(response as unknown as ReadableStream);
}

export default {
    // リクエストハンドラー
	async fetch(request, env, ctx): Promise<Response> {
		// 直下へのリクエスト以外は 404にする (favicon.icoに対しても生成してしまうため)
		if (!request.url.endsWith('/')) return new Response(null, { status: 404 });

		let image: Uint8Array | null = null;
		let attempts = 0;
        // 画像サイズが一定以下であれば再生成を試みる (MAX_ATTEMPTS回まで)
		while (attempts < MAX_ATTEMPTS) {
			if (attempts > 0) await sleep(RETRY_DELAY);

            const prompt = generatePrompt();
			const guidance = Math.random() * 2.0 + 1.0;
			image = await generateImage(env, prompt, guidance);
			const sizeKB = image.length / 1024;
			if (sizeKB >= DESIRED_SIZE_MIN) break;
			attempts++;
		}

		return new Response(image, {
			headers: { 'content-type': 'image/png' },
		});
	},
} satisfies ExportedHandler<Env>;
```

```wrangler.toml

#:schema node_modules/wrangler/config-schema.json
name = "image-gen-example"
main = "src/index.ts"
compatibility_date = "2024-06-10"
compatibility_flags = ["nodejs_compat"]

# Automatically place your workloads in an optimal location to minimize latency.
# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure
# rather than the end user may result in better performance.
# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
# [placement]
# mode = "smart"

# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Docs:
# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
# Note: Use secrets to store sensitive data.
# - https://developers.cloudflare.com/workers/configuration/secrets/
# [vars]
# MY_VARIABLE = "production_value"

# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai
[ai]
binding = "AI"
```

</details>

## 工夫した点

### StableDiffusionのプロンプトはあえてLLMを使わず単純にランダム連結

StableDiffusionに渡すプロンプト自体もLLMに生成してもらう予定でしたが、Cloudflare Workers AIで使えるモデルだと、どうにも上手く単語リストを生成できませんでした。そのため、単純に単語リストをランダムに連結するようにしました。それでも十分多様性ある画像が生成できたため、必須でないことはやらないという選択を取るのも、開発では重要だなと感じました。

### ベースとする英単語をChatGPTで生成

語彙力がなく星に関係する英単語がstar、sparkle、galaxyしか思いつかないので英単語リストを作ってもらいました。

```
あなたは、画像生成AI の Stable Diffusion 用のプロンプトを作成する専門家です。

以下の【プロンプト】は画像を説明しています。
【プロンプト】の中身を画像に再現できるよう、【条件】に合わせて、絶対に文章ではなく、
要素を表す単語、または状況や要素を形容する短いフレーズを
英語で列挙してください。Lang: en

【条件】
・画像に登場させる物体（または人物）、そのポーズや目線やルックス、光の具合、背景、アングル、画風などを、漏らさずに考慮してください。
・並び順は、重要度が高いものほど、先に列挙します。
・要素の区切りには改行を使用します。上記の記号以外に使えるのは、要素の綴りで必要となった時の「-」のみとします。
・文字数を出来るだけ少なくするよう、文章を書くのではなく、要素を列挙してください。

【プロンプト】

リアルな宇宙の壁紙
```

```
realistic space wallpaper
stars
galaxies
nebulae
cosmic dust
deep space
realistic colors
high resolution
distant planets
natural lighting
dark background
3D rendering
ethereal atmosphere
```

</details>

### リトライ処理の一部をCodeiumに訊いて実装

Cloudflare Workers AIでは、稀に生成に失敗して真っ黒の画像を返すことがあるため、VSCode拡張機能の[Codeium](https://codeium.com/)に質問した結果等を使って、[先駆者の方](https://zenn.dev/chimame/articles/224cf1fb343038#%E5%87%BA%E5%8A%9B%E3%81%AE%E5%AE%89%E5%AE%9A%E6%80%A7)同様に、ファイルサイズベースの再試行処理を追記しました。

```
このresponseはReadableStreamオブジェクトです。正しく長さを検証できるように変換してください。
```

```
async function readStream(stream: ReadableStream): Promise<Uint8Array> {
    const reader = stream.getReader();
    const chunks: Uint8Array[] = [];
    let done = false;

    while (!done) {
        const { value, done: _done } = await reader.read();
        if (value) {
            chunks.push(value);
        }
        done = _done;
    }

    return new Uint8Array(chunks.flat());
}

// 使用例
const response = await env.AI.run('@cf/lykon/dreamshaper-8-lcm', {
    prompt: promptWords.join(', '),
});

const uint8ArrayResponse = await readStream(response.body);

console.log(uint8ArrayResponse);
```

### 大雑把に書いたソースコードをPerplexityで整理

リトライ処理を足すと、かなり悲惨なコードになったため整理をしてもらいました

```
下記のNode.jsの処理をリファクタして整理して。

(ここに出すのもはばかられる汚いコード)
```

## 手こずったところ

### 最新のWranglerで `npm run dev` すると常にエラーになる
1か月ほど前からあるバグで、MacOSや Windowsでチュートリアル通りにローカル実行を試みると`ERR_RUNTIME_FAILURE`というエラーが発生します。(Linux以外では動かない...?)

https://github.com/cloudflare/workers-sdk/issues/6260

最悪、動作確認の都度デプロイするというテスト環境を持たない運用もできますが、自分はそうしたくなかったので、Windowsで正常に動く`3.61.0` までダウングレードしました。wranglerのバージョンを下げる場合、`wrangler.toml`の `compatibility_date` も 忘れず `"2024-06-10"` まで下げましょう。

### 生成のシード値が指定できず、常に固定あるいは変動する
ドキュメントによると、このモデルではシード値を指定することができません。1ヶ月ほど前のある時点ではシード値が常に同じで、同じキーワードには完全に同一の結果が返されていました。(そのため、プロンプトの最後にランダムな英数字を差し込んでいました。) この記事を書くにあたり色々と試していたところ、常にシード値がランダムになるよう、変更されていました。

### デフォルトだとやけに濃い色の画像が生成される (要guidance値調整)
1週間ほど前から発生したバグ? で、どぎつい色の画像しか生成されないという課題がでてきました。

![ダウンロード (28).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/c5a90629-fdad-8e39-0d24-0c4d4b4f022b.png)

これは生成モデルに詳しいフォロワーさんに助言を頂き解決することができました。`@cf/lykon/dreamshaper-8-lcm`はLCMモデルであるため、その特性上、生成時に渡すパラメータのguidance値は`1.0~2.0`の範囲内で指定する必要があります。しかし、このモデルのguidanceデフォルト値は`7.5`になっているようで、それが原因となっていました。明示的にguidance値を指定すると意図した色合いで画像が出るようになりました。

### 型定義が間違っているかもしれない
デフォルトでは `env.AI.run` の実行結果の型定義は`Uint8Array`になっていますが、実際に`console.log(response)`で出してみると、入っているのは`ReadableStream`でした。

## 感想
素早く簡単に画像生成APIがデプロイできたのは良かったです。一方で、調べた限りまだまだ先駆者が多くなく、つまずきポイントが多かったので、これをプロダクションで使うのはだいぶ厳しく感じました。もっとAPIが安定してから本格的な利用を検討したいです。

## 参考資料

### プロンプト参考

https://prompt.quel.jp/10107

### コード参考
https://developers.cloudflare.com/workers/get-started/guide/

https://developers.cloudflare.com/workers-ai/models/dreamshaper-8-lcm/#code-examples

https://developers.cloudflare.com/ai-gateway/providers/workersai/#worker

https://zenn.dev/chimame/articles/224cf1fb343038

https://stackoverflow.com/questions/67345903/how-do-i-get-the-size-of-an-image-represented-as-a-unit8array