CLIでサクサク開発！Cloudflare Workersで作るAI画像生成アプリ

この記事は[Cloudflare Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cloudflare) 2日目の記事です🎅

Cloudflareの魅力を知ってもらえるハンズオンを作りました！初めての方で迷わないよう丁寧に解説していきます。Workers AI、楽しいです✨

# 1. はじめに

この記事は、**Cloudflare Workers**と**Workers AI**を使って、AI画像生成アプリをサーバーレスで構築するハンズオンです。

## 1.1 AI画像生成アプリの完成イメージ

日本語プロンプトを英語に翻訳し、英語を使って高品質なAI画像を生成するアプリです。

<img alt="生成された「かわいい猫」の画像" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/032af3e0-038f-ffa2-d5c9-a1a153f2c9bc.png" width="400">

## 1.2 ハンズオンで使用する技術

- **Cloudflare Workers**
  サーバーを持たずにコードを直接エッジネットワーク上で実行できるサーバーレスプラットフォームです。グローバルなエッジロケーションからリクエストを処理するため、高速でスケーラブルなアプリケーションを構築できます

- **Workers AI**
  最新のAIモデルを簡単に統合できるCloudflareのサービスです。テキスト生成・画像生成だけでなく、画像からのオブジェクト抽出や音声処理など様々なAIを簡単に使えます。無料枠があるので気軽に試せます

## 1.3 ハンズオンの特徴

**1. 無料で試せる**

Cloudflare WorkersやWorkers AIの無料枠を利用して、コストをかけずに実践的なアプリケーションを構築できます。

**2. CLIを使ってサクサク開発**

Cloudflareが提供するCLIを活用して、効率的に開発を進められます。

Cloudflare Workersはブラウザーだけで開発することもできますが、使える機能に限りがあります。CLIを使えば、静的コンテンツを無料で配信できる[Static Assets](https://developers.cloudflare.com/workers/static-assets/)も使えるようになります。

**3. AI技術の基礎と応用を学べる**

AIモデルを単体で使う方法だけでなく、英語に翻訳してから画像を生成する、といったAIを組み合わせて使う方法も体験できます。

## 1.4 ハンズオンのコード

この記事で紹介するソースコードは、GitHubリポジトリにも公開しています。ソースコードをまとめて確認したい場合は、以下のリンクにアクセスしてください。

https://github.com/takatama/cf-workers-ai-image-handson-basic

# 2. Cloudflare WorkersでHello World

まずはCloudflare Workersを使った開発プロセスを学ぶため、`Hello World!`と表示する単純なWebアプリを作っていきます。

以下の手順に従って、CLIでWorkersを作っていきます。

https://developers.cloudflare.com/workers/get-started/guide/

## 2.1 事前準備

1. [Cloudflareのアカウントを作成](https://dash.cloudflare.com/sign-up/workers-and-pages)します
   - クレジットカードは不要です

2. Node.jsをインストールします
   - Node.jsは頻繁にアップデートされています。Node.jsのバージョン管理ツール（[Volta](https://volta.sh/)、[nvm](https://github.com/nvm-sh/nvm)など）を使うと、バージョンを指定して実行できるようになります

## 2.2 新しいWorkerプロジェクトを作る

ターミナルで次のコマンドを実行します。
プロジェクト名は好きなものを指定してください。この記事では`ai-image-handson`とします。

```bash
npm create cloudflare@latest -- ai-image-handson
```

:::note info
`--`はコマンドと引数を分ける区切りです。
このコマンドは`create-cloudflare`を実行します。その引数が`--`の後の`ai-image-handson`です。
:::

今回は最もシンプルなWorkerを作ります。次の選択肢を選んでください。
- What would you like to start with?
    - `Hello World example`
- Which template would you like to use?
    - `Hello World Worker`
- Which language do you want to use?
    - `JavaScript`
- Do you want to use git for version control?
    - `Yes`
- Do you want to deploy your application?
    - `No`

これで新しいプロジェクトを作成できました。プロジェクトのディレクトリーに移動しておきます。

```bash
cd ai-image-handson
```

## 2.3 ローカルで動作を確認する

次のコマンドでローカルの開発用サーバーを起動します。

```bash
npx wrangler dev
```

:::note info
`npx`を使うと、まだインストールされていないパッケージをその場で実行できます。
パッケージをグローバルにインストールしないで実行するので、システムを汚さずに済みます。
:::

ターミナルに表示されたURL`http://localhost:8787`をWebブラウザーで開くと、`Hello World!`と表示されているのが確認できます。

## 2.4 コードを修正する

開発用サーバーで実行されているのは`src`ディレクトリーにある`index.js`です。

```javascript:src/index.js
export default {
	async fetch(request, env, ctx) {
		return new Response('Hello World!');
	},
};
```

表示される文字列を`Hello Worker!`に変更してみましょう。

```diff:src/index.js
export default {
	async fetch(request, env, ctx) {
-		return new Response('Hello World!');
+		return new Response('Hello Worker!');
	},
};
```

Webブラウザーをリロードすると、文字列が`Hello Worker!`に変化しました。

## 2.5 デプロイする

ローカルで動作を確認したら、`https://ai-image-handson.<あなたのサブドメイン>.workers.dev`にデプロイします。

ターミナルで次のコマンドを実行します。

```bash
npx wrangler deply
```

なお、デプロイはCLIからだけでなく、[Cloudflare Dashboard](https://dash.cloudflare.com/)からも管理できます。

# 3. AI画像生成アプリを作る

Workers AIを使って、AI画像生成アプリを作っていきましょう。

翻訳機能、画像生成機能、の順番で実装します。

## 3.1 利用するモデルを選ぶ

Workers AIは様々なモデルを提供しています。

https://developers.cloudflare.com/workers-ai/models/

AIの種類（生成する情報）によって、価格が異なります[^1]。日次でリセットされる無料枠があるので、AIの機能を気軽に試すことができます。

https://developers.cloudflare.com/workers-ai/platform/pricing/

[^1]: https://blog.cloudflare.com/workers-ai-bigger-better-faster/#new-workers-ai-pricing ではWorkers AIの料金モデルが10月から変更になると記載されています。ただ、執筆時点（2024年11月）では古いモデルのままのようです。

今回のAI画像生成アプリでは、次の2つのモデルを使います。

- テキスト生成AI（翻訳）
  - [llama-3.2-3b-instruct](https://developers.cloudflare.com/workers-ai/models/llama-3.2-3b-instruct/)
  - マルチリンガルなモデルです。簡単な日本語であれば問題なく英語に翻訳できます（ただ、日本語だけで会話を続けるのは難しかったです）
- 画像生成AI
  - [flux-1-schnell](https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/)
  - 高品質の[^2]画像を高速に[^3]生成します。出力した画像は商用利用可能です（[Hugging Face](https://huggingface.co/black-forest-labs/FLUX.1-schnell)）
  - なお、執筆時点（2024年11月）では同じプロンプトに対して常に同じ画像が生成されます（他の画像生成AIでは同じプロンプトでも異なる画像が生成されます）。Seed値の指定はできません

[^2]: FLUXのパラメーターに`negative_prompt`が**ありません**。
[^3]: FLUXのパラメーターの推論ステップ数`num_steps`はデフォルトが`4`、最大が`8`です。Workers AIの一日当たりの無料枠が250ステップなので、デフォルトで62枚までの画像を生成できます

## 3.2 翻訳の画面を作る

まずは翻訳機能の画面から作っていきます。

### 3.2.1 フロントエンドの`index.html`を作成する

ルートディレクトリーに`public`ディレクトリーを作成します。その配下に`index.html`ファイルを追加して、以下をそれぞれ作成します。

- 日本語を入力する`textarea`
- 翻訳する`button`
- 翻訳結果を表示する`textarea`

なお、見栄えをよくするため[new.css](https://newcss.net/)を使って画面スタイルを整えています[^4]。

[^4]: https://zenn.dev/yusukebe/articles/59532688053828 を参考にさせていただきました！

<details><summary>`public/index.html`を展開する</summary>

```html:public/index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AI画像生成ハンズオン</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.3/new.min.css">
</head>

<body>
  <h3>何をどんな風に描きたいですか？</h3>
  <p>多彩な表現を楽しもう！ポップアート風、油絵風、水彩画風、サイバーパンク風など</p>
  <textarea id="prompt" placeholder="例: かわいい猫のイラスト" style="width: 100%"></textarea>
  <button id="translate-btn" onclick="translateText()">
    英語に翻訳
  </button>
  <p id="translate-error-message" style="color: red;"></p>
  <h3 for="translated-prompt">翻訳結果</h3>
  <textarea id="translated-prompt" placeholder="ここに英語の翻訳結果が表示されます"
    style="width: 100%; field-sizing: content;"></textarea>
  <script></script>
</body>

</html>
```

</details>

### 3.2.2 バックエンドの`index.js`を修正する

`src/index.js`は以下のコードですべて上書きし、Static Assetsを返却するように変更します。

<details>ｓ<summary>`src/index.js`を展開する</summary>

```javascript:src/index.js
export default {
  async fetch(request, env, ctx) {
    return env.ASSETS.fetch(request);
  },
};
```
</details>

### 3.2.3 設定`wrangler.toml`でStatic Assetsを有効にする

Cloudflare Workersでは静的コンテンツ（HTML、CSS、JavaScriptなど）を無料で配信できます。この機能は非常に便利ですが、ブラウザーベースの開発環境では`wrangler.toml`の設定ができないため利用できません。

CLIを使うことで、`wrangler.toml`を簡単に設定し、Static Assetsの無料配信機能を活用することが可能です。これにより、アプリの構築が効率化されるだけでなく、ランニングコストを抑えた運用が可能になります。

Static Assetsを有効にするため、`wrangler.toml`に以下を追加します。

```toml:wrangler.toml
# 追加
assets = { directory = "./public/",  binding = "ASSETS" }
```

開発用サーバーを起動して、`http://localhost:8787`にアクセスし、画面が変わっているか確認しましょう。

<img alt="翻訳機能を追加" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/30fc66d9-df81-a7c0-f0bb-72a87ab10fc6.png" width="400">

まだフロントエンドから翻訳APIを呼び出す機能を実装していないため、翻訳ボタンをしてもエラーが表示されるだけです。

## 3.3 翻訳機能を作る

テキスト生成AIを使って、日本語を英語に翻訳します。

ただ単に翻訳するのではなく、画像生成用にアートスタイルの追加もさせています。

### 3.3.1 フロントエンドから翻訳APIを呼び出す

フロントエンドのロジックを`public/script.js`に実装していきます。

`/public`フォルダーの配下に`script.js`ファイルを追加して、翻訳APIを呼び出します。

<details><summary>`public/script.js`を展開する</summary>

```javascript:public/script.js
async function handleEventStream(response, textarea) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let text = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value, { stream: true });
    chunk.split("\n").forEach((line) => {
      if (line.startsWith("data: ")) {
        const data = line.replace("data: ", "");
        if (data === "[DONE]") return;
        try {
          const jsonData = JSON.parse(data);
          text += jsonData.response;
          textarea.value = text;
        } catch (error) {
          // JSONデータが壊れている場合は無視する
        }
      }
    });
  }
}

async function translateText() {
  const button = document.getElementById("translate-btn");
  const errorMessage = document.getElementById("translate-error-message");
  const promptInput = document.getElementById("prompt");

  button.disabled = true;
  errorMessage.style.display = "none";
  const formData = new FormData();
  formData.set("prompt", promptInput.value);

  try {
    const response = await fetch(`/translate`, {
      method: "POST",
      body: formData,
    });

    if (!response.ok) {
      throw new Error(`エラー ${response.status} ${response.statusText}`);
    }

    const translatedPrompt = document.getElementById("translated-prompt");
    await handleEventStream(response, translatedPrompt);
  } catch (error) {
    console.error("エラー: ", error);
    errorMessage.textContent = "翻訳に失敗しました。もう一度試してください。";
    errorMessage.style.display = "block";
  } finally {
    button.disabled = false;
  }
}
```

</details>

### 3.3.2 `index.html`から`script.js`を呼び出す

`public/index.html`の`<head>`タグ内に、`public/script.js`を読み込むコードを追加します。

```html:public/index.html
<head>
  ...
  <!-- 追加 -->
  <script src="./script.js" defer></script>
</head>
```

### 3.3.3 Workers AIを有効にして翻訳APIを実装する

次に`wrangler.toml`ファイルを変更して、Workers AIを有効にします。2行分のコメントを外します。

```toml:wrangler.toml
[ai]
binding = "AI"
```

そしてバックエンドの`src/index.js`に翻訳APIを実装します。

<details><summary>`src/index.js`を展開する</summary>

```javascript:src/index.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    ...
    // 翻訳APIの呼び出しを追加
    if (request.method === "POST" && url.pathname === "/translate") {
      const formData = await request.formData();
      const prompt = formData.get("prompt");
      return await translatePrompt(prompt, env);
    }
    return env.ASSETS.fetch(request);
  },
};

async function translatePrompt(prompt, env) {
  const messages = [
    {
      role: "system",
      content: `Translate the following Japanese text into a concise and descriptive English prompt for image generation. Ensure the prompt adheres to the specified artistic style (e.g., oil painting, watercolor, pop art, or other specified), but explicitly exclude any cartoon or comic-style elements. Focus on clearly describing essential subjects, actions, and visual details for the image without introducing elements beyond the original content or style. Output the result as a single English sentence without quotation marks, comments, or additional formatting.`
    },
    { role: "user", content: prompt },
  ];

  try {
    const stream = await env.AI.run("@cf/meta/llama-3.2-3b-instruct", {
      messages,
      stream: true,
    });
    return new Response(stream, {
      headers: { "Content-Type": "text/event-stream" },
    });
  } catch (error) {
    console.error("翻訳エラー:", error);
    return new Response(JSON.stringify({ error: "翻訳に失敗しました。" }), {
      status: 500,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }
}
```

</details>

**このコードのポイント：**

- `messages`配列でAIに渡す指示を定義しています
  - `system`ロールで翻訳タスクの詳細を指示しています
  - `user`ロールで実際に翻訳したい日本語のテキストを渡します
- `env.AI.run`メソッドで指定したAIモデルを呼び出し、`stream: true`でレスポンスをストリーミングします

開発用サーバーを起動すると、Workers AIが有効になったことが分かります。

```bash
npx wrangler dev

 ⛅️ wrangler 3.90.0
-------------------

Your worker has access to the following bindings:
- AI:
  - Name: AI
▲ [WARNING] Using Workers AI always accesses your Cloudflare account in order to run AI models, and so will incur usage charges even in local development.
```

`http://localhost:8787`にアクセスし、例えば最初のテキストエリアに「かわいい猫」と入力して「英語に翻訳」ボタンを押してみましょう。翻訳結果が表示されます。

<img alt="翻訳結果を表示" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/3fb951ee-be6a-3929-668b-e63bcc7ee5c2.png" width="400">

なお、ローカルで動作させる場合、翻訳結果がまとめて表示され、ストリーミング表示になっていないように見える場合があります。デプロイすると正しく動作します。

## 3.4 画像生成機能を作る

画像生成AIを使って、翻訳した英語から画像を生成します。

### 3.4.1 画面を作成する

`public/index.html`に画像生成ボタンと生成された画像の表示領域を追加します。

<details><summary>`public/index.html`を展開する</summary>

```html:public/index.html
  <textarea id="translated-prompt" placeholder="ここに英語の翻訳結果が表示されます"
    style="width: 100%; field-sizing: content;"></textarea>
  <!-- 画像生成ボタンを追加 -->
  <button id="gen-image-btn" onclick="generateImage()">
    画像を生成
  </button>
  <p id="gen-image-error-message" class="error-message" style="color: red;"></p>
  <div id="image-container" style="margin-top: 1rem;">
    <img id="generated-image" src="" alt="Generated Image" style="display: none;" />
  </div>
</body>
```

</details>

### 3.4.2 フロントエンドから画像生成APIを呼び出す

フロントエンドの`public/script.js`に以下のコードを追加して、画像生成APIを呼び出します。

<details><summary>`public/script.js`を展開する</summary>

```html:src/index.html
  <script>
    ...
    async function generateImage() {
      const button = document.getElementById("gen-image-btn");
      button.disabled = true;
      const errorMessage = document.getElementById("gen-image-error-message");
      errorMessage.style.display = "none";
      const translatedPrompt = document.getElementById("translated-prompt");
      const formData = new FormData();
      formData.set("prompt", translatedPrompt.value);

      try {
        const response = await fetch(`/generate-image`, {
          method: "POST",
          body: formData,
        });

        if (!response.ok) {
          throw new Error(`エラー ${response.status} ${response.statusText}`);
        }

        const imageBlob = await response.blob();
        const imageUrl = URL.createObjectURL(imageBlob);
        const img = document.getElementById("generated-image");
        img.src = imageUrl;
        img.style.display = "block";
      } catch (error) {
        console.error("エラー: ", error);
        errorMessage.textContent =
          "画像生成に失敗しました。もう一度試すか、プロンプトを変えてください。";
        errorMessage.style.display = "block";
      } finally {
        button.disabled = false;
      }
    }
  </script>
</body>
```

</details>

### 3.4.3 画像生成APIを実装する

`src/index.js`に画像生成APIを追加します。

<details><summary>`src/index.js`を展開する</summary>

```javascript:src/index.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    ...
    // 画像生成APIの呼び出しを追加
    if (request.method === "POST" && url.pathname === "/generate-image") {
      const formData = await request.formData();
      const prompt = formData.get("prompt");
      return await generateImage(prompt, env);
    }
    return env.ASSETS.fetch(request);
  },
};

async function generateImage(prompt, env) {
  const inputs = { prompt };

  try {
    const response = await env.AI.run(
      "@cf/black-forest-labs/flux-1-schnell",
      inputs
    );
    const binaryString = atob(response.image);
    const img = Uint8Array.from(binaryString, (m) => m.codePointAt(0));
    return new Response(img, {
      headers: {
        'Content-Type': 'image/jpeg',
      },
    });
  } catch (error) {
    console.error("画像生成エラー:", error);
    return new Response(JSON.stringify({ error: "画像生成に失敗しました。" }), {
      status: 500,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }
}
```

</details>

開発用サーバーを起動して、`http://localhost:8787`にアクセスし、日本語から英語に翻訳した後、画像を生成ボタンを押しましょう。少し待つと生成された画像が表示されます。

<img alt="生成された「かわいい猫」の画像" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/032af3e0-038f-ffa2-d5c9-a1a153f2c9bc.png" width="400">

執筆時点（2024年11月）では、画像生成のプロンプトが同じだと、常に同じ画像が生成されます。同じ題材で複数の画像を楽しみたい場合は、もう一度[英語に翻訳]を押して別のプロンプトを生成してから、[画像を生成]を実行してください。

## 3.5 デプロイする前に

これでAI画像生成アプリは完成です。デプロイすれば全世界から使えるようになります。

しかし、このアプリにはセキュリティの課題が残っています。全世界へ公開する前に、次のようなセキュリティ対策が必要です。

- ボット対策
  - Cloudflare Turnstileの導入
  - JWT（JSON Web Token）によるセッション管理
- CSRF（Cross Site Request Forgery）対策
  - OriginヘッダーとFetch Metadataによる検証
- XSS（Cross Site Scripting）対策[^5]
  - CSP（Content Security Policy） やサニタイズによるXSS攻撃防御

これらの対策により、不正なアクセスからアプリケーションを守ることができます。次回の記事で詳しく解説します。

[^5]: 今回作成したAI画像生成アプリでは、ユーザーの入力データを画面に表示しなおす機会がありません。XSS攻撃は難しいですが、よくある攻撃なので紹介しています。

# 4. まとめ

この記事を通じて、Cloudflare WorkersとWorkers AIを用いたAI画像生成アプリの構築を体験しました。このハンズオンで学べることを以下にまとめます：

1. **Cloudflare Workersを使った簡単で効率的な開発・デプロイ**  
   サーバー不要で、ローカルのコードを数秒でデプロイし、即座に世界に公開できます。CLIを使った開発ならStatic Assetsを使って静的コンテンツを無料で配信できます

2. **Workers AIを活用した多様なAIモデルの利用**  
   翻訳や画像生成をはじめとする、多彩なAI技術を簡単に試し、アプリケーションに統合できます

3. **AIを利用するアプリケーションの構築体験**  
   翻訳と画像生成を連携させたアプリケーションを設計・実装する流れを体験し、AI技術の応用方法を学べます

次回の記事では、セキュリティを強化してより安全で堅牢なアプリに仕上げていきます。ぜひご覧ください！

# 参考情報

https://zenn.dev/yusukebe/articles/59532688053828

https://blog.jxck.io/entries/2024-04-26/csrf.html