Difyでの画像編集ワークフローの構築

## 🎯 目的 - Goal

Dify 1.3.1 および OpenAI 0.0.1 を組み合わせて、  **プロンプト（テキスト）と画像を入力として受け取る**画像編集ワークフローを構築することを目的とします。

- セルフホスト環境でも安定して動作すること  
- テキストと画像を組み合わせた操作に対応できること  
- 外部サービスに依存せず、カスタマイズ可能な構成を維持すること  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4055742/343c3396-4f38-4787-a616-1fda19ff84a2.png)

## 現状の課題 - Problem

セルフホスト環境においてワークフローを実行した際、タイムアウトエラーが発生し、OpenAI APIとの連携処理が完了せずにワークフローが中断される問題が確認されました。

```
Error processing input image: timed out
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4055742/7525ff6a-cdf1-4581-89e4-5a0cc1a45d67.png)

## 解決方針 - Solution

### 🔁 **OpenAI API の直接利用に切り替え**

Dify 組み込みのプロキシ層を介さず、**OpenAI 画像編集 API を直接呼び出す構成**とすることで、  
**タイムアウトリスクを最小化**し、レスポンスの制御権を自システム側に確保します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4055742/4c3d1e56-2140-4c59-9efd-071bfda175b3.png)

## 🏗 システム構成・実装概要 - System Design

### 🔹 1. 入力処理

- ユーザーは **プロンプト（自然言語）と編集対象の画像**を Dify にアップロード。

### 🔹 2. API 呼び出し

- [OpenAI API（v0.0.1）](https://platform.openai.com/docs/api-reference/images/createEdit)を直接使用し、**プロンプトに基づく画像編集**を実施。  
- API 仕様に応じて `image/edits` や `image/variations` エンドポイントを適宜活用。

### 🔹 3. 出力処理

- 取得した **Base64形式の画像**を Amazon S3 にアップロード。
- **Presigned URL（署名付き一時公開リンク）を生成**し、セキュアに公開。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4055742/e92484a0-259d-486f-8f4f-bc141b1a25e7.png)

<details> <summary> 引数</summary>

| 引数名                  | 型         | 説明                                                |
|-------------------------|------------|-----------------------------------------------------|
| `body`                  | str / dict | 前のHTTPリクエストの結果（Base64データを含むJSON） |
| `get_presigned_url_api` | str        | Presigned URL取得用のAPI Gatewayエンドポイント     |
</details>

<details> <summary> 詳細を表示</summary>

```python
import requests
import base64
import json

def get_presigned_url(api_url):
    """
    プリサインドURLを取得するためにAPIを呼び出す。
    :param api_url: プリサインドURLを取得するAPIのURL。
    :return: プリサインドURL (putとget)を返す。失敗時は例外をスロー。
    """
    try:
        response = requests.get(api_url)
        if response.status_code == 200:
            response_data = response.json()
            presigned_put_url = response_data.get('presigned_put_url')
            presigned_get_url = response_data.get('presigned_get_url')
            if not presigned_put_url or not presigned_get_url:
                raise ValueError("プリサインドURLがAPIレスポンスに含まれていません")
            return presigned_put_url, presigned_get_url
        else:
            raise Exception(f"プリサインドURLの取得に失敗しました: {response.status_code} - {response.text}")
    except Exception as e:
        print(f"プリサインドURLの取得中にエラーが発生しました: {e}")
        raise

def upload_to_s3(base64_data, presigned_put_url, presigned_get_url):
    """
    プリサインドURLを使用してS3にファイルをアップロードする。
    """
    try:
        file_data = base64.b64decode(base64_data)
        response = requests.put(presigned_put_url, data=file_data)
        if response.status_code == 200:
            print("ファイルのアップロードに成功しました")
            return {
                "result": "ファイルのアップロードに成功しました",
                "presigned_get_url": presigned_get_url,
                "status_code": 200
            }
        else:
            print(f"ファイルのアップロードに失敗しました: {response.status_code}")
            return {
                "result": f"ファイルのアップロードに失敗しました: {response.status_code}",
                "presigned_get_url": "",
                "status_code": response.status_code
            }
    except Exception as e:
        print(f"ファイルのアップロード中にエラーが発生しました: {e}")
        return {
            "result": f"ファイルのアップロード中にエラーが発生しました: {e}",
            "presigned_get_url": "",
            "status_code": 500
        }

def main(body, get_presigned_url_api):
    """
    bodyからBase64データを抽出し、S3にアップロードする。
    """
    try:
        if isinstance(body, str):
            try:
                body_object = json.loads(body)
            except json.JSONDecodeError as e:
                return {
                    "result": f"JSONパースに失敗: {str(e)}",
                    "presigned_get_url": "",
                    "status_code": 400
                }
            if isinstance(body_object, str):
                try:
                    body_object = json.loads(body_object)
                except json.JSONDecodeError as e:
                    return {
                        "result": f"2回目のJSONパースに失敗: {str(e)}",
                        "presigned_get_url": "",
                        "status_code": 400
                    }
        else:
            body_object = body

        if not isinstance(body_object, dict):
            return {
                "result": "body_objectが辞書型ではありません",
                "presigned_get_url": "",
                "status_code": 400
            }
        if 'data' not in body_object:
            return {
                "result": "'data'キーが存在しません",
                "presigned_get_url": "",
                "status_code": 400
            }
        if not isinstance(body_object['data'], list) or not body_object['data']:
            return {
                "result": "'data'は空でないリストである必要があります",
                "presigned_get_url": "",
                "status_code": 400
            }
        if not isinstance(body_object['data'][0], dict) or 'b64_json' not in body_object['data'][0]:
            return {
                "result": "data[0]の構造が無効",
                "presigned_get_url": "",
                "status_code": 400
            }

        b64_json = body_object['data'][0]['b64_json']

        try:
            base64.b64decode(b64_json)
        except Exception as e:
            return {
                "result": f"無効なBase64データ: {str(e)}",
                "presigned_get_url": "",
                "status_code": 400
            }

        try:
            presigned_put_url, presigned_get_url = get_presigned_url(get_presigned_url_api)
        except Exception as e:
            return {
                "result": f"プリサインドURLの取得に失敗しました: {str(e)}",
                "presigned_get_url": "",
                "status_code": 500
            }

        result = upload_to_s3(b64_json, presigned_put_url, presigned_get_url)
        return result

    except Exception as e:
        return {
            "result": f"予期しないエラー: {str(e)}",
            "presigned_get_url": "",
            "status_code": 500
        }
```
</details>

▷ 注意

セキュリティ向上のため、`get_presigned_url_api` に対応する API Gateway に **WAF（Web Application Firewall）** を設定することを推奨します。

### 🔹 4. 結果表示

- Dify 上の UI にて、**編集後画像へのアクセスURLを表示**。
- ユーザーはワンクリックで **閲覧・ダウンロードが可能**。

## 🐛 発生した問題と対応策

### 🔸 ① レスポンスサイズ超過によるエラー

#### ■ 内容  
OpenAI API からのレスポンスが **1MB を超過**し、以下のようなエラーが発生：  
`"Text size is too large, max size is 1.00 MB, but current size is 1.56 MB"`

#### ■ 対応  
Dify の環境変数 `HTTP_REQUEST_NODE_MAX_TEXT_SIZE` を **1.5MB 以上に増加**して対応。

#### ✅ 補足  
この変数は Dify v1.3.1 の `node-backend` にて定義されているため、  
**Docker 環境変数** または `.env` ファイルでの設定変更が必要です。
### 🔸 ② Dify の Base64 表示制限

#### ■ 内容  
Dify の UI では Base64 データの**直接出力が制限**されているため、  
生成画像の内容確認が困難な状況。

#### ■ 対応  
編集後の画像を **S3 にアップロード**し、**Presigned URL を生成**。  
外部ブラウザで **安全かつ簡単に表示・保存**できる構成に変更。

✅ この対応により、**Dify のフロントエンドを改修せずに要件を満たす**ことができました。

---

### 🔸 ③ 安全かつ効率的な S3 アップロード方式

#### ■ 内容  
サーバ経由の直接アップロードは、**セキュリティやスケーラビリティに課題**あり。

#### ■ 対応  
**Presigned URL を利用**し、クライアントから S3 へ直接 PUT する方式を採用。  
これにより以下を実現：

- IAM ロールベースの**アクセス制御**
- **有効期限付きの一時的公開**
- **サーバ負荷軽減**と高速処理

---

### 🔸 ④ 複数画像の同時処理（対応中）

#### ■ 内容  
現時点では **単一画像**ごとの処理のみ対応。

#### ■ 課題  
複数画像を一括処理する場合、**負荷分散や同時制御の設計が未整備**。

#### ■ 対応予定  
次フェーズにて、紹介させていただきます。

## 📝 結論（Conclusion）

本構成により：

- **セルフホスト環境での OpenAI API タイムアウト問題を回避**
- **[S3 + Presigned URL](https://qiita.com/lamvn/items/e3bdf5af2e08d8183037) を活用した、安全かつ高速な画像配信**
- **Dify 1.3.1 の制約を環境設定で緩和し、UI変更なしで UX を向上**

が実現できました。

本ワークフローは今後、以下のような応用展開も可能です：

- ✅ 自動バッチ編集、プロンプトテンプレート適用
- ✅ スケーラブルな非同期キュー処理
- ✅ エンタープライズ向け監査ログ・アクセス制限対応

---